From 5be5d590407beec2747c6cac21a31b514c6ea1a0 Mon Sep 17 00:00:00 2001 From: Andrey Zhavoronkov Date: Fri, 18 Oct 2024 12:58:13 +0300 Subject: [PATCH 001/163] Workaround for av context closing issue when using AUTO thread_type (#8555) ### Motivation and context ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **Bug Fixes** - Improved video processing stability by disabling threading in video reading classes, addressing potential memory management issues. - **New Features** - Enhanced video handling capabilities with updated threading parameters for better performance during video processing. --- changelog.d/20241017_155815_andrey_fix_task_creating.md | 4 ++++ cvat/apps/engine/media_extractors.py | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20241017_155815_andrey_fix_task_creating.md diff --git a/changelog.d/20241017_155815_andrey_fix_task_creating.md b/changelog.d/20241017_155815_andrey_fix_task_creating.md new file mode 100644 index 000000000000..e3e8494c205f --- /dev/null +++ b/changelog.d/20241017_155815_andrey_fix_task_creating.md @@ -0,0 +1,4 @@ +### Fixed + +- av context closing issue when using AUTO thread_type + () diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 9c1d2deca189..a64637359ff5 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -552,6 +552,8 @@ def read_av_container(self, source: Union[str, io.BytesIO]) -> av.container.Inpu for stream in container.streams: context = stream.codec_context if context and context.is_open: + # Currently, context closing may get stuck on some videos for an unknown reason, + # so the thread_type == 'AUTO' setting is disabled for future investigation context.close() if container.open_files: @@ -583,7 +585,7 @@ def __init__( stop: Optional[int] = None, dimension: DimensionType = DimensionType.DIM_2D, *, - allow_threading: bool = True, + allow_threading: bool = False, ): super().__init__( source_path=source_path, @@ -635,6 +637,8 @@ def iterate_frames( if self.allow_threading: video_stream.thread_type = 'AUTO' + else: + video_stream.thread_type = 'NONE' frame_counter = itertools.count() with closing(self._decode_stream(container, video_stream)) as stream_decoder: @@ -795,6 +799,8 @@ def iterate_frames(self, *, frame_filter: Iterable[int]) -> Iterable[av.VideoFra video_stream = container.streams.video[0] if self.allow_threading: video_stream.thread_type = 'AUTO' + else: + video_stream.thread_type = 'NONE' container.seek(offset=start_decode_timestamp, stream=video_stream) From 8bc9f159c54f5204c34977e2d603d43b91beb321 Mon Sep 17 00:00:00 2001 From: "cvat-bot[bot]" <147643061+cvat-bot[bot]@users.noreply.github.com> Date: Fri, 18 Oct 2024 10:03:04 +0000 Subject: [PATCH 002/163] Prepare release v2.21.1 --- CHANGELOG.md | 27 +++++++++++++++++++ ...20241009_101726_klakhov_brush_shortcuts.md | 4 --- ...kachev.bs_fixed_state_destructurization.md | 4 --- ...011_142625_sekachev.bs_fixed_navigation.md | 6 ----- ..._task_creation_with_gt_pool_and_cs_data.md | 4 --- changelog.d/20241016_180804_sekachev.bs.md | 4 --- ...0241017_155815_andrey_fix_task_creating.md | 4 --- cvat-cli/requirements/base.txt | 2 +- cvat-cli/src/cvat_cli/version.py | 2 +- cvat-sdk/gen/generate.sh | 2 +- cvat/__init__.py | 2 +- cvat/schema.yml | 2 +- docker-compose.yml | 18 ++++++------- helm-chart/values.yaml | 4 +-- 14 files changed, 43 insertions(+), 42 deletions(-) delete mode 100644 changelog.d/20241009_101726_klakhov_brush_shortcuts.md delete mode 100644 changelog.d/20241011_132931_sekachev.bs_fixed_state_destructurization.md delete mode 100644 changelog.d/20241011_142625_sekachev.bs_fixed_navigation.md delete mode 100644 changelog.d/20241016_142620_maria_fix_task_creation_with_gt_pool_and_cs_data.md delete mode 100644 changelog.d/20241016_180804_sekachev.bs.md delete mode 100644 changelog.d/20241017_155815_andrey_fix_task_creating.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a2cd17ae9868..a195b77dc924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + +## \[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 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/changelog.d/20241017_155815_andrey_fix_task_creating.md b/changelog.d/20241017_155815_andrey_fix_task_creating.md deleted file mode 100644 index e3e8494c205f..000000000000 --- a/changelog.d/20241017_155815_andrey_fix_task_creating.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- av context closing issue when using AUTO thread_type - () diff --git a/cvat-cli/requirements/base.txt b/cvat-cli/requirements/base.txt index e9be53974d91..5d1eadd9b89f 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.21.1 Pillow>=10.3.0 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/cvat-cli/src/cvat_cli/version.py b/cvat-cli/src/cvat_cli/version.py index b2829a54b105..4017971cf6df 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.21.1" diff --git a/cvat-sdk/gen/generate.sh b/cvat-sdk/gen/generate.sh index ca9a08be98fe..553c1182f82b 100755 --- a/cvat-sdk/gen/generate.sh +++ b/cvat-sdk/gen/generate.sh @@ -8,7 +8,7 @@ set -e GENERATOR_VERSION="v6.0.1" -VERSION="2.22.0" +VERSION="2.21.1" LIB_NAME="cvat_sdk" LAYER1_LIB_NAME="${LIB_NAME}/api_client" DST_DIR="$(cd "$(dirname -- "$0")/.." && pwd)" diff --git a/cvat/__init__.py b/cvat/__init__.py index d72cb8e0099c..0acd9281962f 100644 --- a/cvat/__init__.py +++ b/cvat/__init__.py @@ -4,6 +4,6 @@ from cvat.utils.version import get_version -VERSION = (2, 22, 0, 'alpha', 0) +VERSION = (2, 21, 1, 'final', 0) __version__ = get_version(VERSION) diff --git a/cvat/schema.yml b/cvat/schema.yml index 4a33b80c24ae..b06ff69b79ab 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: CVAT REST API - version: 2.22.0 + version: 2.21.1 description: REST API for Computer Vision Annotation Tool (CVAT) termsOfService: https://www.google.com/policies/terms/ contact: diff --git a/docker-compose.yml b/docker-compose.yml index 569e163e9fe5..081e59553289 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,7 @@ services: cvat_server: container_name: cvat_server - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.21.1} restart: always depends_on: <<: *backend-deps @@ -113,7 +113,7 @@ services: cvat_utils: container_name: cvat_utils - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.21.1} restart: always depends_on: *backend-deps environment: @@ -130,7 +130,7 @@ services: cvat_worker_import: container_name: cvat_worker_import - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.21.1} restart: always depends_on: *backend-deps environment: @@ -146,7 +146,7 @@ services: cvat_worker_export: container_name: cvat_worker_export - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.21.1} restart: always depends_on: *backend-deps environment: @@ -162,7 +162,7 @@ services: cvat_worker_annotation: container_name: cvat_worker_annotation - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.21.1} restart: always depends_on: *backend-deps environment: @@ -178,7 +178,7 @@ services: cvat_worker_webhooks: container_name: cvat_worker_webhooks - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.21.1} restart: always depends_on: *backend-deps environment: @@ -194,7 +194,7 @@ services: cvat_worker_quality_reports: container_name: cvat_worker_quality_reports - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.21.1} restart: always depends_on: *backend-deps environment: @@ -210,7 +210,7 @@ services: cvat_worker_analytics_reports: container_name: cvat_worker_analytics_reports - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.21.1} restart: always depends_on: *backend-deps environment: @@ -226,7 +226,7 @@ services: cvat_ui: container_name: cvat_ui - image: cvat/ui:${CVAT_VERSION:-dev} + image: cvat/ui:${CVAT_VERSION:-v2.21.1} restart: always depends_on: - cvat_server diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 05d74e906e98..209266da0b41 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -129,7 +129,7 @@ cvat: additionalVolumeMounts: [] replicas: 1 image: cvat/server - tag: dev + tag: v2.21.1 imagePullPolicy: Always permissionFix: enabled: true @@ -153,7 +153,7 @@ cvat: frontend: replicas: 1 image: cvat/ui - tag: dev + tag: v2.21.1 imagePullPolicy: Always labels: {} # test: test From 6729ecb23fd6997e4a1a2ea134da8cb7d34a7eab Mon Sep 17 00:00:00 2001 From: "cvat-bot[bot]" <147643061+cvat-bot[bot]@users.noreply.github.com> Date: Fri, 18 Oct 2024 11:01:05 +0000 Subject: [PATCH 003/163] Update develop after v2.21.1 --- cvat-cli/requirements/base.txt | 2 +- cvat-cli/src/cvat_cli/version.py | 2 +- cvat-sdk/gen/generate.sh | 2 +- cvat/__init__.py | 2 +- cvat/schema.yml | 2 +- docker-compose.yml | 18 +++++++++--------- helm-chart/values.yaml | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/cvat-cli/requirements/base.txt b/cvat-cli/requirements/base.txt index 5d1eadd9b89f..e9be53974d91 100644 --- a/cvat-cli/requirements/base.txt +++ b/cvat-cli/requirements/base.txt @@ -1,3 +1,3 @@ -cvat-sdk~=2.21.1 +cvat-sdk~=2.22.0 Pillow>=10.3.0 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/cvat-cli/src/cvat_cli/version.py b/cvat-cli/src/cvat_cli/version.py index 4017971cf6df..b2829a54b105 100644 --- a/cvat-cli/src/cvat_cli/version.py +++ b/cvat-cli/src/cvat_cli/version.py @@ -1 +1 @@ -VERSION = "2.21.1" +VERSION = "2.22.0" diff --git a/cvat-sdk/gen/generate.sh b/cvat-sdk/gen/generate.sh index 553c1182f82b..ca9a08be98fe 100755 --- a/cvat-sdk/gen/generate.sh +++ b/cvat-sdk/gen/generate.sh @@ -8,7 +8,7 @@ set -e GENERATOR_VERSION="v6.0.1" -VERSION="2.21.1" +VERSION="2.22.0" LIB_NAME="cvat_sdk" LAYER1_LIB_NAME="${LIB_NAME}/api_client" DST_DIR="$(cd "$(dirname -- "$0")/.." && pwd)" diff --git a/cvat/__init__.py b/cvat/__init__.py index 0acd9281962f..d72cb8e0099c 100644 --- a/cvat/__init__.py +++ b/cvat/__init__.py @@ -4,6 +4,6 @@ from cvat.utils.version import get_version -VERSION = (2, 21, 1, 'final', 0) +VERSION = (2, 22, 0, 'alpha', 0) __version__ = get_version(VERSION) diff --git a/cvat/schema.yml b/cvat/schema.yml index b06ff69b79ab..4a33b80c24ae 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: CVAT REST API - version: 2.21.1 + version: 2.22.0 description: REST API for Computer Vision Annotation Tool (CVAT) termsOfService: https://www.google.com/policies/terms/ contact: diff --git a/docker-compose.yml b/docker-compose.yml index 081e59553289..569e163e9fe5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,7 @@ services: cvat_server: container_name: cvat_server - image: cvat/server:${CVAT_VERSION:-v2.21.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: <<: *backend-deps @@ -113,7 +113,7 @@ services: cvat_utils: container_name: cvat_utils - image: cvat/server:${CVAT_VERSION:-v2.21.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -130,7 +130,7 @@ services: cvat_worker_import: container_name: cvat_worker_import - image: cvat/server:${CVAT_VERSION:-v2.21.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -146,7 +146,7 @@ services: cvat_worker_export: container_name: cvat_worker_export - image: cvat/server:${CVAT_VERSION:-v2.21.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -162,7 +162,7 @@ services: cvat_worker_annotation: container_name: cvat_worker_annotation - image: cvat/server:${CVAT_VERSION:-v2.21.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -178,7 +178,7 @@ services: cvat_worker_webhooks: container_name: cvat_worker_webhooks - image: cvat/server:${CVAT_VERSION:-v2.21.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -194,7 +194,7 @@ services: cvat_worker_quality_reports: container_name: cvat_worker_quality_reports - image: cvat/server:${CVAT_VERSION:-v2.21.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -210,7 +210,7 @@ services: cvat_worker_analytics_reports: container_name: cvat_worker_analytics_reports - image: cvat/server:${CVAT_VERSION:-v2.21.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -226,7 +226,7 @@ services: cvat_ui: container_name: cvat_ui - image: cvat/ui:${CVAT_VERSION:-v2.21.1} + image: cvat/ui:${CVAT_VERSION:-dev} restart: always depends_on: - cvat_server diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 209266da0b41..05d74e906e98 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -129,7 +129,7 @@ cvat: additionalVolumeMounts: [] replicas: 1 image: cvat/server - tag: v2.21.1 + tag: dev imagePullPolicy: Always permissionFix: enabled: true @@ -153,7 +153,7 @@ cvat: frontend: replicas: 1 image: cvat/ui - tag: v2.21.1 + tag: dev imagePullPolicy: Always labels: {} # test: test From e3e8d9998388529504bdded39488232e17369740 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Mon, 21 Oct 2024 14:28:54 +0300 Subject: [PATCH 004/163] Updated page with allocation report (#8558) --- cvat-core/src/api.ts | 5 +- cvat-core/src/index.ts | 5 +- cvat-core/src/server-proxy.ts | 4 +- cvat-core/src/server-response-types.ts | 8 +- cvat-core/src/session-implementation.ts | 15 +- cvat-core/src/session.ts | 6 +- cvat-core/src/validation-layout.ts | 55 ++- cvat-ui/src/actions/annotation-actions.ts | 4 +- .../quality-control/quality-control-page.tsx | 342 +++++++++--------- .../components/quality-control/styles.scss | 7 +- .../task-quality/allocation-table.tsx | 63 +--- .../task-quality/quality-magement-tab.tsx | 15 +- .../quality-control/task-quality/summary.tsx | 23 +- cvat-ui/src/cvat-core-wrapper.ts | 5 +- cvat-ui/src/reducers/index.ts | 4 +- 15 files changed, 290 insertions(+), 271 deletions(-) diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index 5f624ad0e8ae..ca33f431c43e 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -26,7 +26,7 @@ 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'; @@ -427,7 +427,8 @@ function build(): CVATCore { QualityReport, Request, FramesMetaData, - ValidationLayout, + JobValidationLayout, + TaskValidationLayout, }, utils: { mask2Rle, diff --git a/cvat-core/src/index.ts b/cvat-core/src/index.ts index f361f194df73..8a4c9e8bfb53 100644 --- a/cvat-core/src/index.ts +++ b/cvat-core/src/index.ts @@ -32,7 +32,7 @@ import QualityConflict from './quality-conflict'; import QualitySettings from './quality-settings'; import AnalyticsReport from './analytics-report'; import AnnotationGuide from './guide'; -import ValidationLayout from './validation-layout'; +import { JobValidationLayout, TaskValidationLayout } from './validation-layout'; import { Request } from './request'; import BaseSingleFrameAction, { listActions, registerAction, runActions } from './annotations-actions'; import { @@ -216,7 +216,8 @@ export default interface CVATCore { AnalyticsReport: typeof AnalyticsReport; Request: typeof Request; FramesMetaData: typeof FramesMetaData; - ValidationLayout: typeof ValidationLayout; + JobValidationLayout: typeof JobValidationLayout; + TaskValidationLayout: typeof TaskValidationLayout; }; utils: { mask2Rle: typeof mask2Rle; diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index 7e8819808649..be8d3d4fb636 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -19,7 +19,7 @@ import { SerializedInvitationData, SerializedCloudStorage, SerializedFramesMetaData, SerializedCollection, SerializedQualitySettingsData, APIQualitySettingsFilter, SerializedQualityConflictData, APIQualityConflictsFilter, SerializedQualityReportData, APIQualityReportsFilter, SerializedAnalyticsReport, APIAnalyticsReportFilter, - SerializedRequest, SerializedValidationLayout, + SerializedRequest, SerializedJobValidationLayout, SerializedTaskValidationLayout, } from './server-response-types'; import { PaginatedResource } from './core-types'; import { Request } from './request'; @@ -1384,7 +1384,7 @@ async function deleteJob(jobID: number): Promise { const validationLayout = (instance: 'tasks' | 'jobs') => async ( id: number, -): Promise => { +): Promise => { const { backendAPI } = config; try { diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index 5dd8cc3d54d2..e28a6f9ec71a 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -524,8 +524,14 @@ export interface SerializedRequest { owner?: any; } -export interface SerializedValidationLayout { +export interface SerializedJobValidationLayout { honeypot_count?: number; honeypot_frames?: number[]; honeypot_real_frames?: number[]; } + +export interface SerializedTaskValidationLayout extends SerializedJobValidationLayout { + mode: 'gt' | 'gt_pool' | null; + validation_frames?: number[]; + disabled_frames?: number[]; +} diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 38728a409448..47810637db37 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -27,7 +27,10 @@ import { decodePreview, } from './frames'; import Issue from './issue'; -import { SerializedLabel, SerializedTask, SerializedValidationLayout } from './server-response-types'; +import { + SerializedLabel, SerializedTask, SerializedJobValidationLayout, + SerializedTaskValidationLayout, +} from './server-response-types'; import { checkInEnum, checkObjectType } from './common'; import { getCollection, getSaver, clearAnnotations, getAnnotations, @@ -37,7 +40,7 @@ import AnnotationGuide from './guide'; import requestsManager from './requests-manager'; import { Request } from './request'; import User from './user'; -import ValidationLayout from './validation-layout'; +import { JobValidationLayout, TaskValidationLayout } from './validation-layout'; // must be called with task/job context async function deleteFrameWrapper(jobID, frame): Promise { @@ -171,7 +174,7 @@ export function implementJob(Job: typeof JobClass): typeof JobClass { ): ReturnType { const result = await serverProxy.jobs.validationLayout(this.id); if (Object.keys(result).length) { - return new ValidationLayout(result as Required); + return new JobValidationLayout(result as SerializedJobValidationLayout); } return null; @@ -641,9 +644,9 @@ export function implementTask(Task: typeof TaskClass): typeof TaskClass { value: async function validationLayoutImplementation( this: TaskClass, ): ReturnType { - const result = await serverProxy.tasks.validationLayout(this.id); - if (Object.keys(result).length) { - return new ValidationLayout(result as Required); + const result = await serverProxy.tasks.validationLayout(this.id) as SerializedTaskValidationLayout; + if (result.mode !== null) { + return new TaskValidationLayout(result); } return null; diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index cf82aa9a050c..8ecef7e0e632 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -28,7 +28,7 @@ import { Request } from './request'; import logger from './logger'; import Issue from './issue'; import ObjectState from './object-state'; -import ValidationLayout from './validation-layout'; +import { JobValidationLayout, TaskValidationLayout } from './validation-layout'; function buildDuplicatedAPI(prototype) { Object.defineProperties(prototype, { @@ -686,7 +686,7 @@ export class Job extends Session { return result; } - async validationLayout(): Promise { + async validationLayout(): Promise { const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.validationLayout); return result; } @@ -1186,7 +1186,7 @@ export class Task extends Session { return result; } - async validationLayout(): Promise { + async validationLayout(): Promise { const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.validationLayout); return result; } diff --git a/cvat-core/src/validation-layout.ts b/cvat-core/src/validation-layout.ts index ba5a94aa03a9..064af13b2514 100644 --- a/cvat-core/src/validation-layout.ts +++ b/cvat-core/src/validation-layout.ts @@ -2,37 +2,43 @@ // // SPDX-License-Identifier: MIT -import { SerializedValidationLayout } from 'server-response-types'; +import { SerializedJobValidationLayout, SerializedTaskValidationLayout } from 'server-response-types'; import PluginRegistry from './plugins'; -export default class ValidationLayout { - #honeypotFrames: number[]; - #honeypotRealFrames: number[]; +export class JobValidationLayout { + #honeypotCount: JobValidationLayout['honeypotCount']; + #honeypotFrames: JobValidationLayout['honeypotFrames']; + #honeypotRealFrames: JobValidationLayout['honeypotRealFrames']; - public constructor(data: Required) { - this.#honeypotFrames = [...data.honeypot_frames]; - this.#honeypotRealFrames = [...data.honeypot_real_frames]; + public constructor(data: SerializedJobValidationLayout) { + this.#honeypotCount = data.honeypot_count ?? 0; + this.#honeypotFrames = [...(data.honeypot_frames ?? [])]; + this.#honeypotRealFrames = [...(data.honeypot_real_frames ?? [])]; } - public get honeypotFrames() { + public get honeypotCount(): number { + return this.#honeypotCount; + } + + public get honeypotFrames(): number[] { return [...this.#honeypotFrames]; } - public get honeypotRealFrames() { + public get honeypotRealFrames(): number[] { return [...this.#honeypotRealFrames]; } async getRealFrame(frame: number): Promise { - const result = await PluginRegistry.apiWrapper.call(this, ValidationLayout.prototype.getRealFrame, frame); + const result = await PluginRegistry.apiWrapper.call(this, JobValidationLayout.prototype.getRealFrame, frame); return result; } } -Object.defineProperties(ValidationLayout.prototype.getRealFrame, { +Object.defineProperties(JobValidationLayout.prototype.getRealFrame, { implementation: { writable: false, enumerable: false, - value: function implementation(this: ValidationLayout, frame: number): number | null { + value: function implementation(this: JobValidationLayout, frame: number): number | null { const index = this.honeypotFrames.indexOf(frame); if (index !== -1) { return this.honeypotRealFrames[index]; @@ -42,3 +48,28 @@ Object.defineProperties(ValidationLayout.prototype.getRealFrame, { }, }, }); + +export class TaskValidationLayout extends JobValidationLayout { + #mode: TaskValidationLayout['mode']; + #validationFrames: TaskValidationLayout['validationFrames']; + #disabledFrames: TaskValidationLayout['disabledFrames']; + + public constructor(data: SerializedTaskValidationLayout) { + super(data); + this.#mode = data.mode; + this.#validationFrames = [...(data.validation_frames ?? [])]; + this.#disabledFrames = [...(data.disabled_frames ?? [])]; + } + + public get mode(): NonNullable { + return this.#mode; + } + + public get validationFrames(): number[] { + return [...this.#validationFrames]; + } + + public get disabledFrames(): number[] { + return [...this.#disabledFrames]; + } +} diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 9e3eeb8176b3..670ace099e5a 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -12,7 +12,7 @@ import { } from 'cvat-canvas-wrapper'; import { getCore, MLModel, JobType, Job, QualityConflict, - ObjectState, JobState, ValidationLayout, + ObjectState, JobState, JobValidationLayout, } from 'cvat-core-wrapper'; import logger, { EventScope } from 'cvat-logger'; import { getCVATStore } from 'cvat-store'; @@ -38,7 +38,7 @@ interface AnnotationsParameters { showGroundTruth: boolean; jobInstance: Job; groundTruthInstance: Job | null; - validationLayout: ValidationLayout | null; + validationLayout: JobValidationLayout | null; } const cvat = getCore(); diff --git a/cvat-ui/src/components/quality-control/quality-control-page.tsx b/cvat-ui/src/components/quality-control/quality-control-page.tsx index 64e475321cfa..cb00382db30f 100644 --- a/cvat-ui/src/components/quality-control/quality-control-page.tsx +++ b/cvat-ui/src/components/quality-control/quality-control-page.tsx @@ -13,10 +13,11 @@ import { Row, Col } from 'antd/lib/grid'; import Tabs, { TabsProps } from 'antd/lib/tabs'; import Title from 'antd/lib/typography/Title'; import notification from 'antd/lib/notification'; -import { useIsMounted } from 'utils/hooks'; +import Result from 'antd/lib/result'; + import { - Job, JobType, QualityReport, QualitySettings, Task, getCore, FramesMetaData, - TargetMetric, + Job, JobType, QualityReport, QualitySettings, Task, + TargetMetric, TaskValidationLayout, getCore, FramesMetaData, } from 'cvat-core-wrapper'; import CVATLoadingSpinner from 'components/common/loading-spinner'; import GoBackButton from 'components/common/go-back-button'; @@ -32,20 +33,18 @@ function getTabFromHash(supportedTabs: string[]): string { return supportedTabs.includes(tab) ? tab : supportedTabs[0]; } -type InstanceType = 'task'; - interface State { fetching: boolean; reportRefreshingStatus: string | null; - gtJob: { - instance: Job | null, - meta: FramesMetaData | null, - }, + validationLayout: TaskValidationLayout | null; + gtJobInstance: Job | null; + gtJobMeta: FramesMetaData | null; + error: Error | null; qualitySettings: { settings: QualitySettings | null; fetching: boolean; targetMetric: TargetMetric | null; - }, + }; } enum ReducerActionType { @@ -57,6 +56,8 @@ enum ReducerActionType { SET_REPORT_REFRESHING_STATUS = 'SET_REPORT_REFRESHING_STATUS', SET_GT_JOB = 'SET_GT_JOB', SET_GT_JOB_META = 'SET_GT_JOB_META', + SET_VALIDATION_LAYOUT = 'SET_VALIDATION_LAYOUT', + SET_ERROR = 'SET_ERROR', } export const reducerActions = { @@ -78,11 +79,17 @@ export const reducerActions = { setReportRefreshingStatus: (status: string | null) => ( createAction(ReducerActionType.SET_REPORT_REFRESHING_STATUS, { status }) ), - setGtJob: (job: Job | null) => ( - createAction(ReducerActionType.SET_GT_JOB, { job }) + setGtJob: (gtJobInstance: Job | null) => ( + createAction(ReducerActionType.SET_GT_JOB, { gtJobInstance }) ), - setGtJobMeta: (meta: FramesMetaData | null) => ( - createAction(ReducerActionType.SET_GT_JOB_META, { meta }) + setGtJobMeta: (gtJobMeta: FramesMetaData | null) => ( + createAction(ReducerActionType.SET_GT_JOB_META, { gtJobMeta }) + ), + setValidationLayout: (validationLayout: TaskValidationLayout | null) => ( + createAction(ReducerActionType.SET_VALIDATION_LAYOUT, { validationLayout }) + ), + setError: (error: Error) => ( + createAction(ReducerActionType.SET_ERROR, { error }) ), }; @@ -125,20 +132,28 @@ const reducer = (state: State, action: ActionUnion): Stat if (action.type === ReducerActionType.SET_GT_JOB) { return { ...state, - gtJob: { - ...state.gtJob, - instance: action.payload.job, - }, + gtJobInstance: action.payload.gtJobInstance, }; } if (action.type === ReducerActionType.SET_GT_JOB_META) { return { ...state, - gtJob: { - ...state.gtJob, - meta: action.payload.meta, - }, + gtJobMeta: action.payload.gtJobMeta, + }; + } + + if (action.type === ReducerActionType.SET_VALIDATION_LAYOUT) { + return { + ...state, + validationLayout: action.payload.validationLayout, + }; + } + + if (action.type === ReducerActionType.SET_ERROR) { + return { + ...state, + error: action.payload.error, }; } @@ -146,87 +161,59 @@ const reducer = (state: State, action: ActionUnion): Stat }; function QualityControlPage(): JSX.Element { + const supportedTabs = ['overview', 'settings', 'management']; const [state, dispatch] = useReducer(reducer, { fetching: true, reportRefreshingStatus: null, - gtJob: { - instance: null, - meta: null, - }, + gtJobInstance: null, + gtJobMeta: null, + validationLayout: null, + error: null, qualitySettings: { settings: null, - fetching: true, + fetching: false, targetMetric: null, }, }); - const requestedInstanceType: InstanceType = 'task'; const requestedInstanceID = +useParams<{ tid: string }>().tid; - const [instanceType, setInstanceType] = useState(null); - const [instance, setInstance] = useState(null); - const isMounted = useIsMounted(); - - const supportedTabs = ['overview', 'settings', 'management']; const [activeTab, setActiveTab] = useState(getTabFromHash(supportedTabs)); - const receiveInstance = async (type: InstanceType, id: number): Promise => { - let receivedInstance: Task | null = null; - let gtJob: Job | null = null; - let gtJobMeta: FramesMetaData | null = null; + const [instance, setInstance] = useState(null); + const initializeData = async (id: number): Promise => { try { - if (type === 'task') { - [receivedInstance] = await core.tasks.get({ id }); - gtJob = receivedInstance.jobs.find((job: Job) => job.type === JobType.GROUND_TRUTH) ?? null; - if (gtJob) { - gtJobMeta = await core.frames.getMeta('job', gtJob.id) as FramesMetaData; - } - } else { - return null; + let taskInstance = null; + try { + [taskInstance] = await core.tasks.get({ id }); + } catch (error: unknown) { + throw new Error('The task was not found on the server'); + } + + setInstance(taskInstance); + try { + dispatch(reducerActions.setQualitySettingsFetching(true)); + const settings = await core.analytics.quality.settings.get({ taskID: taskInstance.id }); + dispatch(reducerActions.setQualitySettings(settings)); + } finally { + dispatch(reducerActions.setQualitySettingsFetching(false)); } - if (isMounted()) { + const gtJob = taskInstance.jobs.find((job: Job) => job.type === JobType.GROUND_TRUTH) ?? null; + if (gtJob) { + const validationLayout: TaskValidationLayout | null = await taskInstance.validationLayout(); + const gtJobMeta = await core.frames.getMeta('job', gtJob.id) as FramesMetaData; dispatch(reducerActions.setGtJob(gtJob)); dispatch(reducerActions.setGtJobMeta(gtJobMeta)); - setInstance(receivedInstance); - setInstanceType(type); + dispatch(reducerActions.setValidationLayout(validationLayout)); } - return receivedInstance; } catch (error: unknown) { - notification.error({ - message: `Could not receive requested ${type}`, - description: `${error instanceof Error ? error.message : ''}`, - }); - return null; + dispatch(reducerActions.setError(error instanceof Error ? error : new Error('Unknown error'))); + } finally { + dispatch(reducerActions.setFetching(false)); } }; - const receiveSettings = useCallback(async (taskInstance: Task) => { - dispatch(reducerActions.setQualitySettingsFetching(true)); - - function handleError(error: Error): void { - if (isMounted()) { - notification.error({ - description: error.toString(), - message: 'Could not initialize quality control page', - }); - } - } - - try { - const settingsRequest = core.analytics.quality.settings.get({ taskID: taskInstance.id }); - - await Promise.all([settingsRequest]).then(([settings]) => { - dispatch(reducerActions.setQualitySettings(settings)); - }).catch(handleError).finally(() => { - dispatch(reducerActions.setQualitySettingsFetching(false)); - dispatch(reducerActions.setFetching(false)); - }); - } catch (error: unknown) { - handleError(error as Error); - } - }, [instance]); - const onSaveQualitySettings = useCallback(async (values) => { try { const { settings } = state.qualitySettings; @@ -274,48 +261,41 @@ function QualityControlPage(): JSX.Element { } }, [state.qualitySettings.settings]); - const updateMeta = (action: (frameID: number) => void) => async (frameIDs: number[]): Promise => { - const { instance: gtJob } = state.gtJob; - if (gtJob) { - dispatch(reducerActions.setFetching(true)); - await Promise.all(frameIDs.map((frameID: number): void => action(frameID))); - const [newMeta] = await gtJob.frames.save(); + const updateMeta = async (): Promise => { + dispatch(reducerActions.setFetching(true)); + try { + const [newMeta] = await (state.gtJobInstance as Job).frames.save(); + const validationLayout: TaskValidationLayout | null = await (instance as Task).validationLayout(); dispatch(reducerActions.setGtJobMeta(newMeta)); + dispatch(reducerActions.setValidationLayout(validationLayout)); + } finally { dispatch(reducerActions.setFetching(false)); } }; - const onDeleteFrames = useCallback( - updateMeta((frameID: number) => (state.gtJob.instance?.frames.delete(frameID))), - [state.gtJob.instance], - ); - - const onRestoreFrames = useCallback( - updateMeta((frameID: number) => (state.gtJob.instance?.frames.restore(frameID))), - [state.gtJob.instance], - ); + const onDeleteFrames = useCallback((frameIDs: number[]): void => { + if (state.gtJobInstance && instance) { + for (const frameID of frameIDs) { + state.gtJobInstance.frames.delete(frameID); + } - useEffect(() => { - if (Number.isInteger(requestedInstanceID) && ['task'].includes(requestedInstanceType)) { - dispatch(reducerActions.setFetching(true)); - receiveInstance(requestedInstanceType, requestedInstanceID).then((task) => { - if (task) { - receiveSettings(task); - } - }); - } else { - notification.error({ - message: 'Could not load this page', - description: `Not valid resource ${requestedInstanceType} #${requestedInstanceID}`, - }); + updateMeta(); } + }, [state.gtJobInstance]); - return () => { - if (isMounted()) { - setInstance(null); + const onRestoreFrames = useCallback((frameIDs: number[]): void => { + if (state.gtJobInstance && instance) { + for (const frameID of frameIDs) { + state.gtJobInstance.frames.restore(frameID); } - }; - }, [requestedInstanceType, requestedInstanceID]); + + updateMeta(); + } + }, [state.gtJobInstance]); + + useEffect(() => { + initializeData(requestedInstanceID); + }, [requestedInstanceID]); useEffect(() => { window.addEventListener('hashchange', () => { @@ -332,16 +312,22 @@ function QualityControlPage(): JSX.Element { setActiveTab(key); }, []); - let backNavigation: JSX.Element | null = null; + const backNavigation: JSX.Element | null = ( + + + + + + ); let title: JSX.Element | null = null; let tabs: JSX.Element | null = null; const { fetching, - gtJob: { - instance: gtJobInstance, - meta: gtJobMeta, - }, + gtJobInstance, + gtJobMeta, + validationLayout, + error, qualitySettings: { settings: qualitySettings, fetching: qualitySettingsFetching, @@ -349,53 +335,73 @@ function QualityControlPage(): JSX.Element { }, } = state; - const settingsInitialized = qualitySettings && targetMetric; - if (instanceType && instance && settingsInitialized) { - backNavigation = ( - - - - - + if (error) { + return ( +
+
+ +
+
); + } - const qualityControlFor = {`Task #${instance.id}`}; + if (fetching || qualitySettingsFetching) { + return ( +
+
+ +
+
+ ); + } + + if (instance) { title = ( Quality control for - {' '} - {qualityControlFor} + <Link to={`/tasks/${instance.id}`}>{` Task #${instance.id}`}</Link> ); - const tabsItems: [NonNullable[0], number][] = []; - tabsItems.push([{ - key: 'overview', - label: 'Overview', - children: ( - - ), - }, 10]); + const tabsItems: NonNullable[0][] = []; - if (gtJobInstance && gtJobMeta) { - tabsItems.push([{ - key: 'management', - label: 'Management', + if (targetMetric) { + tabsItems.push({ + key: 'overview', + label: 'Overview', children: ( - + ), - }, 20]); + }); + } - tabsItems.push([{ + if (gtJobInstance && gtJobMeta) { + if (validationLayout) { + tabsItems.push({ + key: 'management', + label: 'Management', + children: ( + + ), + }); + } + + tabsItems.push({ key: 'settings', label: 'Settings', children: ( @@ -405,11 +411,9 @@ function QualityControlPage(): JSX.Element { setQualitySettings={onSaveQualitySettings} /> ), - }, 30]); + }); } - tabsItems.sort((item1, item2) => item1[1] - item2[1]); - tabs = ( item[0])} + items={tabsItems} /> ); } return (
- {fetching && qualitySettingsFetching ? ( -
- -
- ) : ( - - - {backNavigation} - - - {title} - {tabs} - - - - - )} + + + {backNavigation} + + + {title} + {tabs} + + + +
); } diff --git a/cvat-ui/src/components/quality-control/styles.scss b/cvat-ui/src/components/quality-control/styles.scss index 6149d317d105..70a8e2fcea81 100644 --- a/cvat-ui/src/components/quality-control/styles.scss +++ b/cvat-ui/src/components/quality-control/styles.scss @@ -115,10 +115,11 @@ $excluded-background: #d9d9d973; } } -.cvat-quality-control-loading { +.cvat-quality-control-loading, .cvat-quality-control-page-error { position: absolute; - right: 50%; - margin-top: 20%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } .cvat-quality-control-overview-tab { diff --git a/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx b/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx index 363d08374001..43ac48e4f335 100644 --- a/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx +++ b/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: MIT -import { range } from 'lodash'; import React, { useState } from 'react'; import { useHistory } from 'react-router'; import { useSelector } from 'react-redux'; @@ -15,14 +14,15 @@ import { Key } from 'antd/lib/table/interface'; import Icon, { DeleteOutlined } from '@ant-design/icons'; import { RestoreIcon } from 'icons'; -import { Task, Job, FramesMetaData } from 'cvat-core-wrapper'; +import { Task, FramesMetaData, TaskValidationLayout } from 'cvat-core-wrapper'; import CVATTooltip from 'components/common/cvat-tooltip'; import { sorter } from 'utils/quality'; interface Props { task: Task; - gtJob: Job; + gtJobId: number; gtJobMeta: FramesMetaData; + validationLayout: TaskValidationLayout; onDeleteFrames: (frames: number[]) => void; onRestoreFrames: (frames: number[]) => void; } @@ -33,52 +33,9 @@ interface RowData { active: boolean; } -interface TableRowData extends RowData { - key: Key; -} - -// Temporary solution: this function is necessary in one of plugins which imports it directly from CVAT code -// Further this solution should be re-designed -// Until then, *DO NOT RENAME/REMOVE* this exported function -export function getAllocationTableContents(gtJobMeta: FramesMetaData, gtJob: Job): TableRowData[] { - // A workaround for meta "includedFrames" using source data numbers - // TODO: remove once meta is migrated to relative frame numbers - - const jobFrameNumbers = gtJobMeta.getDataFrameNumbers().map((dataFrameNumber: number) => ( - gtJobMeta.getJobRelativeFrameNumber(dataFrameNumber) + gtJob.startFrame - )); - - const jobDataSegmentFrameNumbers = range( - gtJobMeta.startFrame, gtJobMeta.stopFrame + 1, gtJobMeta.frameStep, - ); - - let includedIndex = 0; - const result: TableRowData[] = []; - for (let index = 0; index < jobDataSegmentFrameNumbers.length; ++index) { - const dataFrameID = jobDataSegmentFrameNumbers[index]; - - if (gtJobMeta.includedFrames && !gtJobMeta.includedFrames.includes(dataFrameID)) { - continue; - } - - const frameID = jobFrameNumbers[includedIndex]; - - result.push({ - key: frameID, - frame: frameID, - name: gtJobMeta.frames[index]?.name ?? gtJobMeta.frames[0].name, - active: !(frameID in gtJobMeta.deletedFrames), - }); - - ++includedIndex; - } - - return result; -} - function AllocationTable(props: Readonly): JSX.Element { const { - task, gtJob, gtJobMeta, + task, gtJobId, gtJobMeta, validationLayout, onDeleteFrames, onRestoreFrames, } = props; @@ -88,7 +45,13 @@ function AllocationTable(props: Readonly): JSX.Element { selectedRows: [], }); - const data = getAllocationTableContents(gtJobMeta, gtJob); + const { disabledFrames } = validationLayout; + const data = validationLayout.validationFrames.map((frame: number, index: number) => ({ + key: frame, + frame, + name: gtJobMeta.frames[index]?.name ?? gtJobMeta.frames[0].name, + active: !disabledFrames.includes(frame), + })); const columns = [ { @@ -104,7 +67,7 @@ function AllocationTable(props: Readonly): JSX.Element { type='link' onClick={(e: React.MouseEvent): void => { e.preventDefault(); - history.push(`/tasks/${task.id}/jobs/${gtJob.id}?frame=${frame}`); + history.push(`/tasks/${task.id}/jobs/${gtJobId}?frame=${frame}`); }} > {`#${frame}`} @@ -125,7 +88,7 @@ function AllocationTable(props: Readonly): JSX.Element { type='link' onClick={(e: React.MouseEvent): void => { e.preventDefault(); - history.push(`/tasks/${task.id}/jobs/${gtJob.id}?frame=${record.frame}`); + history.push(`/tasks/${task.id}/jobs/${gtJobId}?frame=${record.frame}`); }} > {name} diff --git a/cvat-ui/src/components/quality-control/task-quality/quality-magement-tab.tsx b/cvat-ui/src/components/quality-control/task-quality/quality-magement-tab.tsx index 03e6103db861..b54fc19e94af 100644 --- a/cvat-ui/src/components/quality-control/task-quality/quality-magement-tab.tsx +++ b/cvat-ui/src/components/quality-control/task-quality/quality-magement-tab.tsx @@ -6,14 +6,15 @@ import React from 'react'; import { Row, Col } from 'antd/es/grid'; import Spin from 'antd/lib/spin'; -import { FramesMetaData, Job, Task } from 'cvat-core-wrapper'; +import { FramesMetaData, Task, TaskValidationLayout } from 'cvat-core-wrapper'; import AllocationTable from './allocation-table'; import SummaryComponent from './summary'; interface Props { task: Task; - gtJob: Job; + gtJobId: number; gtJobMeta: FramesMetaData; + validationLayout: TaskValidationLayout; fetching: boolean; onDeleteFrames: (frames: number[]) => void; onRestoreFrames: (frames: number[]) => void; @@ -21,12 +22,12 @@ interface Props { function QualityManagementTab(props: Readonly): JSX.Element { const { - task, gtJob, gtJobMeta, fetching, + task, gtJobId, gtJobMeta, fetching, validationLayout, onDeleteFrames, onRestoreFrames, } = props; - const totalCount = gtJobMeta.getDataFrameNumbers().length; - const excludedCount = Object.keys(gtJobMeta.deletedFrames).length; + const totalCount = validationLayout.validationFrames.length; + const excludedCount = validationLayout.disabledFrames.length; const activeCount = totalCount - excludedCount; return ( @@ -41,6 +42,7 @@ function QualityManagementTab(props: Readonly): JSX.Element { ): JSX.Element { diff --git a/cvat-ui/src/components/quality-control/task-quality/summary.tsx b/cvat-ui/src/components/quality-control/task-quality/summary.tsx index 59767192ed0d..30fc859feb54 100644 --- a/cvat-ui/src/components/quality-control/task-quality/summary.tsx +++ b/cvat-ui/src/components/quality-control/task-quality/summary.tsx @@ -5,40 +5,51 @@ import React from 'react'; import { Row, Col } from 'antd/es/grid'; import Text from 'antd/lib/typography/Text'; + import AnalyticsCard from 'components/analytics-page/views/analytics-card'; export interface Props { + mode: 'gt' | 'gt_pool' excludedCount: number; totalCount: number; activeCount: number; } export default function SummaryComponent(props: Readonly): JSX.Element { - const { excludedCount, totalCount, activeCount } = props; + const { + excludedCount, totalCount, activeCount, mode, + } = props; const reportInfo = ( - + - Excluded count: + Validation mode: {' '} - {excludedCount} + {mode === 'gt' ? 'Ground Truth' : 'Honeypots'} - Total count: + Total validation frames: {' '} {totalCount} + + + Excluded validation frames: + {' '} + {excludedCount} + + - Active count: + Active validation frames: {' '} {activeCount} diff --git a/cvat-ui/src/cvat-core-wrapper.ts b/cvat-ui/src/cvat-core-wrapper.ts index dac86011953a..275cedcc8ab9 100644 --- a/cvat-ui/src/cvat-core-wrapper.ts +++ b/cvat-ui/src/cvat-core-wrapper.ts @@ -35,7 +35,7 @@ import Comment from 'cvat-core/src/comment'; import User from 'cvat-core/src/user'; import Organization, { Membership, Invitation } from 'cvat-core/src/organization'; import AnnotationGuide from 'cvat-core/src/guide'; -import ValidationLayout from 'cvat-core/src/validation-layout'; +import { JobValidationLayout, TaskValidationLayout } from 'cvat-core/src/validation-layout'; import AnalyticsReport, { AnalyticsEntryViewType, AnalyticsEntry } from 'cvat-core/src/analytics-report'; import { Dumper } from 'cvat-core/src/annotation-formats'; import { Event } from 'cvat-core/src/event'; @@ -106,7 +106,8 @@ export { ActionParameterType, FrameSelectionType, Request, - ValidationLayout, + JobValidationLayout, + TaskValidationLayout, }; export type { diff --git a/cvat-ui/src/reducers/index.ts b/cvat-ui/src/reducers/index.ts index 999d7d6c5419..a9b89d20cff7 100644 --- a/cvat-ui/src/reducers/index.ts +++ b/cvat-ui/src/reducers/index.ts @@ -8,7 +8,7 @@ import { Canvas, RectDrawingMethod, CuboidDrawingMethod } from 'cvat-canvas-wrap import { Webhook, MLModel, Organization, Job, Task, Project, Label, User, QualityConflict, FramesMetaData, RQStatus, Event, Invitation, SerializedAPISchema, - Request, TargetMetric, ValidationLayout, + Request, TargetMetric, JobValidationLayout, } from 'cvat-core-wrapper'; import { IntelligentScissors } from 'utils/opencv-wrapper/intelligent-scissors'; import { KeyMap, KeyMapItem } from 'utils/mousetrap-react'; @@ -730,7 +730,7 @@ export interface AnnotationState { defaultPointsCount: number | null; }; groundTruthInfo: { - validationLayout: ValidationLayout | null; + validationLayout: JobValidationLayout | null; groundTruthJobFramesMeta: FramesMetaData | null; groundTruthInstance: Job | null; }, From 329e94da0432880ec76c4a26527a6e8ad26d682f Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Mon, 21 Oct 2024 15:32:14 +0300 Subject: [PATCH 005/163] Add a mechanism for statically defining periodic background jobs (#8552) My main motivation was really to add a mechanism to statically define periodic jobs in the project configuration (which I need to do for a feature I'm working on). Such a mechanism is needed for a few reasons: 1. There's no good place to put code that creates periodic jobs. You could do it on app initialization, but that means you have to run Redis to use `manage.py`, which is inconvenient for development; and it causes much more Redis traffic than necessary, since the code will run whenever any backend service starts (or restarts). This patch fixes this by adding a new management command, `syncperiodicjobs`, that runs once per deployment. 2. It allows to find and delete jobs that are no longer needed. This is done by recording all created jobs in a Redis set. Without this, even when you delete the code that creates a job, the job itself would persist in Redis forever and require manual intervention to delete. 3. It contains common logic to recreate a job when its parameters change. Besides the general mechanism, I also added one concrete job that cleans up expired Django sessions. This helps to demonstrate the mechanism, and is useful in its own right. --- .vscode/launch.json | 16 ++++ backend_entrypoint.sh | 3 + .../20241017_133125_roman_periodic_jobs.md | 4 + cvat/apps/engine/management/__init__.py | 0 .../engine/management/commands/__init__.py | 0 .../management/commands/syncperiodicjobs.py | 76 +++++++++++++++++++ cvat/apps/iam/utils.py | 8 ++ cvat/settings/base.py | 9 +++ .../contributing/development-environment.md | 1 + 9 files changed, 117 insertions(+) create mode 100644 changelog.d/20241017_133125_roman_periodic_jobs.md create mode 100644 cvat/apps/engine/management/__init__.py create mode 100644 cvat/apps/engine/management/commands/__init__.py create mode 100644 cvat/apps/engine/management/commands/syncperiodicjobs.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 5ed666059a9d..af93ae24c007 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -376,6 +376,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", diff --git a/backend_entrypoint.sh b/backend_entrypoint.sh index c8b681eabb4d..39c1d7d90cc1 100755 --- a/backend_entrypoint.sh +++ b/backend_entrypoint.sh @@ -18,6 +18,9 @@ cmd_bash() { cmd_init() { wait_for_db ~/manage.py migrate + + ~/wait-for-it.sh "${CVAT_REDIS_INMEM_HOST}:${CVAT_REDIS_INMEM_PORT:-6379}" -t 0 + ~/manage.py syncperiodicjobs } cmd_run() { diff --git a/changelog.d/20241017_133125_roman_periodic_jobs.md b/changelog.d/20241017_133125_roman_periodic_jobs.md new file mode 100644 index 000000000000..8a68b33f919f --- /dev/null +++ b/changelog.d/20241017_133125_roman_periodic_jobs.md @@ -0,0 +1,4 @@ +### Fixed + +- Expired sessions are now cleared from the database daily + () diff --git a/cvat/apps/engine/management/__init__.py b/cvat/apps/engine/management/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cvat/apps/engine/management/commands/__init__.py b/cvat/apps/engine/management/commands/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cvat/apps/engine/management/commands/syncperiodicjobs.py b/cvat/apps/engine/management/commands/syncperiodicjobs.py new file mode 100644 index 000000000000..097f468b337f --- /dev/null +++ b/cvat/apps/engine/management/commands/syncperiodicjobs.py @@ -0,0 +1,76 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from argparse import ArgumentParser +from collections import defaultdict + +from django.core.management.base import BaseCommand +from django.conf import settings + +import django_rq + +class Command(BaseCommand): + help = "Synchronize periodic jobs in Redis with the project configuration" + + _PERIODIC_JOBS_KEY_PREFIX = 'cvat:utils:periodic-jobs:' + + def add_arguments(self, parser: ArgumentParser) -> None: + parser.add_argument('--clear', action='store_true', help='Remove jobs from Redis instead of updating them') + + def handle(self, *args, **options): + configured_jobs = defaultdict(dict) + + if not options["clear"]: + for job in settings.PERIODIC_RQ_JOBS: + configured_jobs[job['queue']][job['id']] = job + + for queue_name in settings.RQ_QUEUES: + self.stdout.write(f"Processing queue {queue_name}...") + + periodic_jobs_key = self._PERIODIC_JOBS_KEY_PREFIX + queue_name + + queue = django_rq.get_queue(queue_name) + scheduler = django_rq.get_scheduler(queue_name, queue=queue) + + stored_jobs_for_queue = { + member.decode('UTF-8') for member in queue.connection.smembers(periodic_jobs_key) + } + configured_jobs_for_queue = configured_jobs[queue_name] + + # Delete jobs that are no longer in the configuration + jobs_to_delete = stored_jobs_for_queue.difference(configured_jobs_for_queue.keys()) + + for job_id in jobs_to_delete: + self.stdout.write(f"Deleting job {job_id}...") + scheduler.cancel(job_id) + if job := queue.fetch_job(job_id): + job.delete() + + queue.connection.srem(periodic_jobs_key, job_id) + + # Add/update jobs from the configuration + for job_definition in configured_jobs_for_queue.values(): + job_id = job_definition['id'] + + if job := queue.fetch_job(job_id): + if ( + job.func_name == job_definition['func'] + and job.meta.get('cron_string') == job_definition['cron_string'] + ): + self.stdout.write(f"Job {job_id} is unchanged") + queue.connection.sadd(periodic_jobs_key, job_id) + continue + + self.stdout.write(f"Recreating job {job_id}...") + job.delete() + else: + self.stdout.write(f"Creating job {job_id}...") + + scheduler.cron( + cron_string=job_definition['cron_string'], + func=job_definition['func'], + id=job_id, + ) + + queue.connection.sadd(periodic_jobs_key, job_id) diff --git a/cvat/apps/iam/utils.py b/cvat/apps/iam/utils.py index 9cd122ab1ba3..a13de3367336 100644 --- a/cvat/apps/iam/utils.py +++ b/cvat/apps/iam/utils.py @@ -2,9 +2,13 @@ from typing import Tuple import functools import hashlib +import importlib import io import tarfile +from django.conf import settings +from django.contrib.sessions.backends.base import SessionBase + _OPA_RULES_PATHS = { Path(__file__).parent / 'rules', } @@ -43,3 +47,7 @@ def get_dummy_user(email): if email.verified: return None return user + +def clean_up_sessions() -> None: + SessionStore: type[SessionBase] = importlib.import_module(settings.SESSION_ENGINE).SessionStore + SessionStore.clear_expired() diff --git a/cvat/settings/base.py b/cvat/settings/base.py index d698fe563531..52a5f37eb38e 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -339,6 +339,15 @@ class CVAT_QUEUES(Enum): 'cvat.apps.events.handlers.handle_rq_exception', ] +PERIODIC_RQ_JOBS = [ + { + 'queue': CVAT_QUEUES.CLEANING.value, + 'id': 'clean_up_sessions', + 'func': 'cvat.apps.iam.utils.clean_up_sessions', + 'cron_string': '0 0 * * *', + }, +] + # JavaScript and CSS compression # https://django-compressor.readthedocs.io diff --git a/site/content/en/docs/contributing/development-environment.md b/site/content/en/docs/contributing/development-environment.md index cf2b1c01d713..e54929609e48 100644 --- a/site/content/en/docs/contributing/development-environment.md +++ b/site/content/en/docs/contributing/development-environment.md @@ -168,6 +168,7 @@ description: 'Installing a development environment for different operating syste ```bash python manage.py migrate python manage.py collectstatic + python manage.py syncperiodicjobs python manage.py createsuperuser ``` From 4354f72f9ced78ef4e507d88949cf7780aa38229 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Mon, 21 Oct 2024 18:29:07 +0300 Subject: [PATCH 006/163] Pass quality settings to corresponding components (#8571) --- .../quality-control/quality-control-page.tsx | 14 +++++--------- .../task-quality/allocation-table.tsx | 5 ++++- .../task-quality/quality-magement-tab.tsx | 19 ++++++++----------- .../task-quality/quality-overview-tab.tsx | 4 ++-- cvat-ui/src/reducers/index.ts | 8 +++++--- 5 files changed, 24 insertions(+), 26 deletions(-) diff --git a/cvat-ui/src/components/quality-control/quality-control-page.tsx b/cvat-ui/src/components/quality-control/quality-control-page.tsx index cb00382db30f..09f7cfe5e4d8 100644 --- a/cvat-ui/src/components/quality-control/quality-control-page.tsx +++ b/cvat-ui/src/components/quality-control/quality-control-page.tsx @@ -17,7 +17,7 @@ import Result from 'antd/lib/result'; import { Job, JobType, QualityReport, QualitySettings, Task, - TargetMetric, TaskValidationLayout, getCore, FramesMetaData, + TaskValidationLayout, getCore, FramesMetaData, } from 'cvat-core-wrapper'; import CVATLoadingSpinner from 'components/common/loading-spinner'; import GoBackButton from 'components/common/go-back-button'; @@ -43,7 +43,6 @@ interface State { qualitySettings: { settings: QualitySettings | null; fetching: boolean; - targetMetric: TargetMetric | null; }; } @@ -107,7 +106,6 @@ const reducer = (state: State, action: ActionUnion): Stat qualitySettings: { ...state.qualitySettings, settings: action.payload.qualitySettings, - targetMetric: action.payload.qualitySettings.targetMetric, }, }; } @@ -172,7 +170,6 @@ function QualityControlPage(): JSX.Element { qualitySettings: { settings: null, fetching: false, - targetMetric: null, }, }); @@ -331,7 +328,6 @@ function QualityControlPage(): JSX.Element { qualitySettings: { settings: qualitySettings, fetching: qualitySettingsFetching, - targetMetric, }, } = state; @@ -372,18 +368,18 @@ function QualityControlPage(): JSX.Element { const tabsItems: NonNullable[0][] = []; - if (targetMetric) { + if (qualitySettings) { tabsItems.push({ key: 'overview', label: 'Overview', children: ( - + ), }); } if (gtJobInstance && gtJobMeta) { - if (validationLayout) { + if (validationLayout && qualitySettings) { tabsItems.push({ key: 'management', label: 'Management', @@ -393,9 +389,9 @@ function QualityControlPage(): JSX.Element { gtJobId={gtJobInstance.id} gtJobMeta={gtJobMeta} validationLayout={validationLayout} + qualitySettings={qualitySettings} onDeleteFrames={onDeleteFrames} onRestoreFrames={onRestoreFrames} - fetching={fetching} /> ), }); diff --git a/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx b/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx index 43ac48e4f335..914909bc90c1 100644 --- a/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx +++ b/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx @@ -14,7 +14,9 @@ import { Key } from 'antd/lib/table/interface'; import Icon, { DeleteOutlined } from '@ant-design/icons'; import { RestoreIcon } from 'icons'; -import { Task, FramesMetaData, TaskValidationLayout } from 'cvat-core-wrapper'; +import { + Task, FramesMetaData, TaskValidationLayout, QualitySettings, +} from 'cvat-core-wrapper'; import CVATTooltip from 'components/common/cvat-tooltip'; import { sorter } from 'utils/quality'; @@ -23,6 +25,7 @@ interface Props { gtJobId: number; gtJobMeta: FramesMetaData; validationLayout: TaskValidationLayout; + qualitySettings: QualitySettings; onDeleteFrames: (frames: number[]) => void; onRestoreFrames: (frames: number[]) => void; } diff --git a/cvat-ui/src/components/quality-control/task-quality/quality-magement-tab.tsx b/cvat-ui/src/components/quality-control/task-quality/quality-magement-tab.tsx index b54fc19e94af..6acc2770be3e 100644 --- a/cvat-ui/src/components/quality-control/task-quality/quality-magement-tab.tsx +++ b/cvat-ui/src/components/quality-control/task-quality/quality-magement-tab.tsx @@ -4,9 +4,11 @@ import React from 'react'; import { Row, Col } from 'antd/es/grid'; -import Spin from 'antd/lib/spin'; -import { FramesMetaData, Task, TaskValidationLayout } from 'cvat-core-wrapper'; +import { + FramesMetaData, QualitySettings, + Task, TaskValidationLayout, +} from 'cvat-core-wrapper'; import AllocationTable from './allocation-table'; import SummaryComponent from './summary'; @@ -15,14 +17,15 @@ interface Props { gtJobId: number; gtJobMeta: FramesMetaData; validationLayout: TaskValidationLayout; - fetching: boolean; + qualitySettings: QualitySettings; onDeleteFrames: (frames: number[]) => void; onRestoreFrames: (frames: number[]) => void; } function QualityManagementTab(props: Readonly): JSX.Element { const { - task, gtJobId, gtJobMeta, fetching, validationLayout, + task, gtJobId, gtJobMeta, + validationLayout, qualitySettings, onDeleteFrames, onRestoreFrames, } = props; @@ -32,13 +35,6 @@ function QualityManagementTab(props: Readonly): JSX.Element { return (
- { - fetching && ( -
- -
- ) - } ): JSX.Element { gtJobId={gtJobId} gtJobMeta={gtJobMeta} validationLayout={validationLayout} + qualitySettings={qualitySettings} onDeleteFrames={onDeleteFrames} onRestoreFrames={onRestoreFrames} /> diff --git a/cvat-ui/src/components/quality-control/task-quality/quality-overview-tab.tsx b/cvat-ui/src/components/quality-control/task-quality/quality-overview-tab.tsx index 7bb03ebb31e0..04042a66678f 100644 --- a/cvat-ui/src/components/quality-control/task-quality/quality-overview-tab.tsx +++ b/cvat-ui/src/components/quality-control/task-quality/quality-overview-tab.tsx @@ -6,13 +6,13 @@ import React from 'react'; import { useSelector } from 'react-redux'; import config from 'config'; -import { TargetMetric, Task } from 'cvat-core-wrapper'; +import { QualitySettings, Task } from 'cvat-core-wrapper'; import { CombinedState } from 'reducers'; import PaidFeaturePlaceholder from 'components/paid-feature-placeholder/paid-feature-placeholder'; interface Props { task: Task; - targetMetric: TargetMetric; + qualitySettings: QualitySettings; } function QualityOverviewTab(): JSX.Element { diff --git a/cvat-ui/src/reducers/index.ts b/cvat-ui/src/reducers/index.ts index a9b89d20cff7..6c297cd5f4ac 100644 --- a/cvat-ui/src/reducers/index.ts +++ b/cvat-ui/src/reducers/index.ts @@ -8,7 +8,7 @@ import { Canvas, RectDrawingMethod, CuboidDrawingMethod } from 'cvat-canvas-wrap import { Webhook, MLModel, Organization, Job, Task, Project, Label, User, QualityConflict, FramesMetaData, RQStatus, Event, Invitation, SerializedAPISchema, - Request, TargetMetric, JobValidationLayout, + Request, JobValidationLayout, QualitySettings, TaskValidationLayout, } from 'cvat-core-wrapper'; import { IntelligentScissors } from 'utils/opencv-wrapper/intelligent-scissors'; import { KeyMap, KeyMapItem } from 'utils/mousetrap-react'; @@ -269,14 +269,16 @@ export interface PluginsState { qualityControlPage: { overviewTab: ((props: { task: Task; - targetMetric: TargetMetric; + qualitySettings: QualitySettings; }) => JSX.Element)[]; allocationTable: (( props: { task: Task; - gtJob: Job; + gtJobId: number; gtJobMeta: FramesMetaData; + qualitySettings: QualitySettings; + validationLayout: TaskValidationLayout; onDeleteFrames: (frames: number[]) => void; onRestoreFrames: (frames: number[]) => void; }) => JSX.Element)[]; From 2cca2dd3cc61290aeac138443979cd28571e5846 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Tue, 22 Oct 2024 13:05:59 +0300 Subject: [PATCH 007/163] Fixed tooltips with undefined shortcuts (#8578) --- .../20241022_121246_sekachev.bs_fixed_undefined_shortcuts.md | 4 ++++ .../standard-workspace/objects-side-bar/object-item.tsx | 2 +- .../controls-side-bar/draw-shape-popover.tsx | 2 +- .../controls-side-bar/setup-tag-popover.tsx | 2 +- cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 changelog.d/20241022_121246_sekachev.bs_fixed_undefined_shortcuts.md diff --git a/changelog.d/20241022_121246_sekachev.bs_fixed_undefined_shortcuts.md b/changelog.d/20241022_121246_sekachev.bs_fixed_undefined_shortcuts.md new file mode 100644 index 000000000000..567059d51985 --- /dev/null +++ b/changelog.d/20241022_121246_sekachev.bs_fixed_undefined_shortcuts.md @@ -0,0 +1,4 @@ +### Fixed + +- Fixed some interface tooltips having 'undefined' shortcuts + () diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx index 9e6ff1f609c2..30811abad1cd 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx @@ -118,7 +118,7 @@ function ObjectItemComponent(props: Props): JSX.Element { propagateShortcut={normalizedKeyMap.PROPAGATE_OBJECT} toBackgroundShortcut={normalizedKeyMap.TO_BACKGROUND} toForegroundShortcut={normalizedKeyMap.TO_FOREGROUND} - removeShortcut={normalizedKeyMap.DELETE_OBJECT} + removeShortcut={normalizedKeyMap.DELETE_OBJECT_STANDARD_WORKSPACE} changeColorShortcut={normalizedKeyMap.CHANGE_OBJECT_COLOR} sliceShortcut={normalizedKeyMap.SWITCH_SLICE_MODE} changeLabel={changeLabel} diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx index 45325a7ec6ac..50e7ac511832 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx @@ -204,7 +204,7 @@ class DrawShapePopoverContainer extends React.PureComponent { numberOfPoints={numberOfPoints} rectDrawingMethod={rectDrawingMethod} cuboidDrawingMethod={cuboidDrawingMethod} - repeatShapeShortcut={normalizedKeyMap.SWITCH_DRAW_MODE} + repeatShapeShortcut={normalizedKeyMap.SWITCH_DRAW_MODE_STANDARD_CONTROLS} onChangeLabel={this.onChangeLabel} onChangePoints={this.onChangePoints} onChangeRectDrawingMethod={this.onChangeRectDrawingMethod} diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/setup-tag-popover.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/setup-tag-popover.tsx index 392dfa11785a..1c6236956197 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/setup-tag-popover.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/setup-tag-popover.tsx @@ -154,7 +154,7 @@ class DrawShapePopoverContainer extends React.PureComponent { diff --git a/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx b/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx index 4d81fa0822c2..e9d2785faf10 100644 --- a/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx @@ -697,7 +697,7 @@ class AnnotationTopBarContainer extends React.PureComponent { redoAction={redoAction} undoShortcut={normalizedKeyMap.UNDO} redoShortcut={normalizedKeyMap.REDO} - drawShortcut={normalizedKeyMap.SWITCH_DRAW_MODE} + drawShortcut={normalizedKeyMap.SWITCH_DRAW_MODE_STANDARD_CONTROLS} switchToolsBlockerShortcut={normalizedKeyMap.SWITCH_TOOLS_BLOCKER_STATE} playPauseShortcut={normalizedKeyMap.PLAY_PAUSE} deleteFrameShortcut={normalizedKeyMap.DELETE_FRAME} From 1d4632cccbf0903056d251a4eb4190fcd517785d Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Tue, 22 Oct 2024 14:15:33 +0300 Subject: [PATCH 008/163] Remove our copy of the wait-for-it script (#8572) Nowadays it's available from the Ubuntu repositories, so install it from there. This declutters our root directory a bit, plus it makes enumerating 3rd-party dependencies easier. --- Dockerfile | 3 +- backend_entrypoint.sh | 4 +- wait-for-it.sh | 178 ------------------------------------------ wait_for_deps.sh | 6 +- 4 files changed, 7 insertions(+), 184 deletions(-) delete mode 100755 wait-for-it.sh 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/backend_entrypoint.sh b/backend_entrypoint.sh index 39c1d7d90cc1..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() { @@ -19,7 +19,7 @@ cmd_init() { wait_for_db ~/manage.py migrate - ~/wait-for-it.sh "${CVAT_REDIS_INMEM_HOST}:${CVAT_REDIS_INMEM_PORT:-6379}" -t 0 + wait-for-it "${CVAT_REDIS_INMEM_HOST}:${CVAT_REDIS_INMEM_PORT:-6379}" -t 0 ~/manage.py syncperiodicjobs } diff --git a/wait-for-it.sh b/wait-for-it.sh deleted file mode 100755 index 12f10ee7dcd7..000000000000 --- a/wait-for-it.sh +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env bash -# Use this script to test if a given TCP host/port are available -# https://github.com/vishnubob/wait-for-it - -cmdname=$(basename $0) - -echoerr() { if [[ $QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } - -usage() -{ - cat << USAGE >&2 -Usage: - $cmdname host:port [-s] [-t timeout] [-- command args] - -h HOST | --host=HOST Host or IP under test - -p PORT | --port=PORT TCP port under test - Alternatively, you specify the host and port as host:port - -s | --strict Only execute subcommand if the test succeeds - -q | --quiet Don't output any status messages - -t TIMEOUT | --timeout=TIMEOUT - Timeout in seconds, zero for no timeout - -- COMMAND ARGS Execute command with args after the test finishes -USAGE - exit 1 -} - -wait_for() -{ - if [[ $TIMEOUT -gt 0 ]]; then - echoerr "$cmdname: waiting $TIMEOUT seconds for $HOST:$PORT" - else - echoerr "$cmdname: waiting for $HOST:$PORT without a timeout" - fi - start_ts=$(date +%s) - while : - do - if [[ $ISBUSY -eq 1 ]]; then - nc -z $HOST $PORT - result=$? - else - (echo > /dev/tcp/$HOST/$PORT) >/dev/null 2>&1 - result=$? - fi - if [[ $result -eq 0 ]]; then - end_ts=$(date +%s) - echoerr "$cmdname: $HOST:$PORT is available after $((end_ts - start_ts)) seconds" - break - fi - sleep 1 - done - return $result -} - -wait_for_wrapper() -{ - # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 - if [[ $QUIET -eq 1 ]]; then - timeout $BUSYTIMEFLAG $TIMEOUT $0 --quiet --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & - else - timeout $BUSYTIMEFLAG $TIMEOUT $0 --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & - fi - PID=$! - trap "kill -INT -$PID" INT - wait $PID - RESULT=$? - if [[ $RESULT -ne 0 ]]; then - echoerr "$cmdname: timeout occurred after waiting $TIMEOUT seconds for $HOST:$PORT" - fi - return $RESULT -} - -# process arguments -while [[ $# -gt 0 ]] -do - case "$1" in - *:* ) - hostport=(${1//:/ }) - HOST=${hostport[0]} - PORT=${hostport[1]} - shift 1 - ;; - --child) - CHILD=1 - shift 1 - ;; - -q | --quiet) - QUIET=1 - shift 1 - ;; - -s | --strict) - STRICT=1 - shift 1 - ;; - -h) - HOST="$2" - if [[ $HOST == "" ]]; then break; fi - shift 2 - ;; - --host=*) - HOST="${1#*=}" - shift 1 - ;; - -p) - PORT="$2" - if [[ $PORT == "" ]]; then break; fi - shift 2 - ;; - --port=*) - PORT="${1#*=}" - shift 1 - ;; - -t) - TIMEOUT="$2" - if [[ $TIMEOUT == "" ]]; then break; fi - shift 2 - ;; - --timeout=*) - TIMEOUT="${1#*=}" - shift 1 - ;; - --) - shift - CLI=("$@") - break - ;; - --help) - usage - ;; - *) - echoerr "Unknown argument: $1" - usage - ;; - esac -done - -if [[ "$HOST" == "" || "$PORT" == "" ]]; then - echoerr "Error: you need to provide a host and port to test." - usage -fi - -TIMEOUT=${TIMEOUT:-15} -STRICT=${STRICT:-0} -CHILD=${CHILD:-0} -QUIET=${QUIET:-0} - -# check to see if timeout is from busybox? -# check to see if timeout is from busybox? -TIMEOUT_PATH=$(realpath $(which timeout)) -if [[ $TIMEOUT_PATH =~ "busybox" ]]; then - ISBUSY=1 - BUSYTIMEFLAG="-t" -else - ISBUSY=0 - BUSYTIMEFLAG="" -fi - -if [[ $CHILD -gt 0 ]]; then - wait_for - RESULT=$? - exit $RESULT -else - if [[ $TIMEOUT -gt 0 ]]; then - wait_for_wrapper - RESULT=$? - else - wait_for - RESULT=$? - fi -fi - -if [[ $CLI != "" ]]; then - if [[ $RESULT -ne 0 && $STRICT -eq 1 ]]; then - echoerr "$cmdname: strict mode, refusing to execute subprocess" - exit $RESULT - fi - exec "${CLI[@]}" -else - exit $RESULT -fi diff --git a/wait_for_deps.sh b/wait_for_deps.sh index c78950cf96c4..6cf96886fd69 100755 --- a/wait_for_deps.sh +++ b/wait_for_deps.sh @@ -11,8 +11,8 @@ # but it's too resource-intensive to execute for every worker we might be running # in a container. Instead, it's in backend_entrypoint.sh. -~/wait-for-it.sh "${CVAT_POSTGRES_HOST}:${CVAT_POSTGRES_PORT:-5432}" -t 0 -~/wait-for-it.sh "${CVAT_REDIS_INMEM_HOST}:${CVAT_REDIS_INMEM_PORT}" -t 0 -~/wait-for-it.sh "${CVAT_REDIS_ONDISK_HOST}:${CVAT_REDIS_ONDISK_PORT}" -t 0 +wait-for-it "${CVAT_POSTGRES_HOST}:${CVAT_POSTGRES_PORT:-5432}" -t 0 +wait-for-it "${CVAT_REDIS_INMEM_HOST}:${CVAT_REDIS_INMEM_PORT}" -t 0 +wait-for-it "${CVAT_REDIS_ONDISK_HOST}:${CVAT_REDIS_ONDISK_PORT}" -t 0 exec "$@" From 036b17ae0eb601aeab5ac4f6b0288a638a3abc3d Mon Sep 17 00:00:00 2001 From: Alecto Date: Tue, 22 Oct 2024 13:27:28 +0200 Subject: [PATCH 009/163] Fix grafana container restart policy (#8577) ### Motivation and context Fixes #8576 ### How has this been tested? I've tried it on my CVAT installation and it works. ### Checklist - [x] I submit my changes into the `develop` branch - [x] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [x] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced a new service, `cvat_grafana`, for enhanced Grafana container management. - Configured automatic restart and integrated with ClickHouse environment settings. - Added authentication options and plugin installations for Grafana. - Custom entrypoint script for setup of data sources and dashboards. - **Bug Fixes** - Resolved issues with the Grafana container restart policy to ensure proper management. --- changelog.d/20241022_114627_gui-u.md | 4 ++++ docker-compose.yml | 1 + 2 files changed, 5 insertions(+) create mode 100644 changelog.d/20241022_114627_gui-u.md diff --git a/changelog.d/20241022_114627_gui-u.md b/changelog.d/20241022_114627_gui-u.md new file mode 100644 index 000000000000..efa896e33289 --- /dev/null +++ b/changelog.d/20241022_114627_gui-u.md @@ -0,0 +1,4 @@ +### Fixed + +- Fix Grafana container restart policy + () diff --git a/docker-compose.yml b/docker-compose.yml index 569e163e9fe5..0d3f802c82f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -329,6 +329,7 @@ services: cvat_grafana: image: grafana/grafana-oss:10.1.2 + restart: always container_name: cvat_grafana environment: <<: *clickhouse-env From 5045f6a6d80d73276f1f662fa32e9545a3acfe7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gordon=20B=C3=B6er?= <1067159+gboeer@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:08:45 +0200 Subject: [PATCH 010/163] fix broken nuclio doc links (#8582) --- site/content/en/docs/manual/advanced/serverless-tutorial.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/content/en/docs/manual/advanced/serverless-tutorial.md b/site/content/en/docs/manual/advanced/serverless-tutorial.md index 7355d22d982e..5211886208e8 100644 --- a/site/content/en/docs/manual/advanced/serverless-tutorial.md +++ b/site/content/en/docs/manual/advanced/serverless-tutorial.md @@ -974,9 +974,9 @@ you can use the Ubuntu subsystem, for this do the following: [detectron2-tutorial]: https://detectron2.readthedocs.io/en/latest/tutorials/getting_started.html [retinanet-model-zoo]: https://github.com/facebookresearch/detectron2/blob/master/MODEL_ZOO.md#retinanet [faster-rcnn-function]: https://raw.githubusercontent.com/cvat-ai/cvat/38b774046d41d604ed85a521587e4bacce61b69c/serverless/tensorflow/faster_rcnn_inception_v2_coco/nuclio/function.yaml -[nuclio-doc]: https://nuclio.io/docs/latest/reference/function-configuration/function-configuration-reference/ -[nuclio-http-trigger-doc]: https://nuclio.io/docs/latest/reference/triggers/http/ -[nuclio-bkms-doc]: https://nuclio.io/docs/latest/concepts/best-practices-and-common-pitfalls/ +[nuclio-doc]: https://nuclio.io/docs/latest/reference/function-configuration/function-configuration-reference.html +[nuclio-http-trigger-doc]: https://nuclio.io/docs/latest/reference/triggers/http.html +[nuclio-bkms-doc]: https://nuclio.io/docs/latest/concepts/best-practices-and-common-pitfalls.html [retinanet-function-yaml]: https://github.com/cvat-ai/cvat/blob/b2f616859ca64687c385e636b4a25014fbb9d17c/serverless/pytorch/facebookresearch/detectron2/retinanet/nuclio/function.yaml [retinanet-main-py]: https://github.com/cvat-ai/cvat/blob/b2f616859ca64687c385e636b4a25014fbb9d17c/serverless/pytorch/facebookresearch/detectron2/retinanet/nuclio/main.py [nuclio-homepage]: https://nuclio.io/ From bcd06276811bed67e340458a4f143785e5878aaa Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Tue, 22 Oct 2024 20:04:32 +0300 Subject: [PATCH 011/163] Fix a bug where retrying an export RQ job may break scheduling (#8584) `_patched_retry` tries to schedule a copy of the current job. In particular, it copies the dependencies of the old job using `current_rq_job.dependency_ids`. Unfortunately, `dependency_ids` does not return IDs of dependency jobs, as one might expect. It actually returns the Redis _keys_ corresponding to those jobs, as bytestrings. The RQ job creation code does not support bytestrings as dependency specifiers, so it unintentionally treats them as sequences, saving the individual bytes (as integers) as the dependency job IDs. But since IDs have to be strings, the scheduler quickly crashes when it tries to use those integer "IDs" to construct Redis keys. Thankfully, we don't actually need to get the dependency IDs. `_patched_retry` is only used inside running jobs, and if a job is running, it means that all its dependencies are already completed. Thus, the newly scheduled job doesn't need to have any dependencies at all. --- .../20241022_191618_roman_fix_integer_dependencies.md | 5 +++++ cvat/apps/dataset_manager/views.py | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20241022_191618_roman_fix_integer_dependencies.md diff --git a/changelog.d/20241022_191618_roman_fix_integer_dependencies.md b/changelog.d/20241022_191618_roman_fix_integer_dependencies.md new file mode 100644 index 000000000000..974f29bc63c5 --- /dev/null +++ b/changelog.d/20241022_191618_roman_fix_integer_dependencies.md @@ -0,0 +1,5 @@ +### Fixed + +- Fixed a bug where an export RQ job being retried may break scheduling + of new jobs + () diff --git a/cvat/apps/dataset_manager/views.py b/cvat/apps/dataset_manager/views.py index 35e40c8c03a3..1dbff55ed08d 100644 --- a/cvat/apps/dataset_manager/views.py +++ b/cvat/apps/dataset_manager/views.py @@ -85,7 +85,6 @@ def _patched_retry(*_1, **_2): **current_rq_job.kwargs, job_id=current_rq_job.id, meta=current_rq_job.meta, - depends_on=current_rq_job.dependency_ids, job_ttl=current_rq_job.ttl, job_result_ttl=current_rq_job.result_ttl, job_description=current_rq_job.description, From 13fac287bd34b5c7d1fa79e97cc5ddf428f54056 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Wed, 23 Oct 2024 09:23:45 +0300 Subject: [PATCH 012/163] Fix typos in UI code (#8583) --- cvat-core/src/annotations-collection.ts | 2 +- cvat-core/src/cloud-storage.ts | 2 +- cvat-core/src/frames.ts | 4 ++-- cvat-core/src/object-utils.ts | 2 +- cvat-core/src/requests-manager.ts | 2 +- cvat-core/src/server-proxy.ts | 2 +- cvat-core/src/session-implementation.ts | 4 ++-- cvat-data/src/ts/3rdparty/README.md | 4 ++-- cvat-data/src/ts/unzip_imgs.worker.ts | 2 +- cvat-ui/react_nginx.conf | 2 +- cvat-ui/src/components/model-runner-modal/object-mapper.tsx | 2 +- .../components/tasks-page/automatic-annotation-progress.tsx | 2 +- cvat-ui/src/utils/environment.ts | 2 +- cvat-ui/src/utils/opencv-wrapper/opencv-wrapper.ts | 2 +- 14 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cvat-core/src/annotations-collection.ts b/cvat-core/src/annotations-collection.ts index 291fcc6c3e97..14879e86bcd8 100644 --- a/cvat-core/src/annotations-collection.ts +++ b/cvat-core/src/annotations-collection.ts @@ -1295,7 +1295,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; 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/frames.ts b/cvat-core/src/frames.ts index 1192058c11b3..b29335865d01 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -683,7 +683,7 @@ export function getContextImage(jobID: number, frame: number): Promise setTimeout(checkAndExecute)); } else { @@ -775,7 +775,7 @@ export async function getFrame( // - getContextImage // - getCachedChunks // And from this idea we should call refreshJobCacheIfOutdated from each one - // Hovewer, following from the order, these methods are usually called + // However, following from the order, these methods are usually called // it may lead to even more confusing behaviour // // Usually user first receives frame, then user receives ranges and finally user receives context images diff --git a/cvat-core/src/object-utils.ts b/cvat-core/src/object-utils.ts index 0c4a3e5d8143..6e7fcbbd8d8c 100644 --- a/cvat-core/src/object-utils.ts +++ b/cvat-core/src/object-utils.ts @@ -60,7 +60,7 @@ export function findAngleDiff(rightAngle: number, leftAngle: number): number { angleDiff = ((angleDiff + 180) % 360) - 180; if (Math.abs(angleDiff) >= 180) { // if the main arc is bigger than 180, go another arc - // to find it, just substract absolute value from 360 and inverse sign + // to find it, just subtract absolute value from 360 and inverse sign angleDiff = 360 - Math.abs(angleDiff) * Math.sign(angleDiff) * -1; } return angleDiff; diff --git a/cvat-core/src/requests-manager.ts b/cvat-core/src/requests-manager.ts index 429c42dba2f3..c348923e68bc 100644 --- a/cvat-core/src/requests-manager.ts +++ b/cvat-core/src/requests-manager.ts @@ -74,7 +74,7 @@ class RequestsManager { const promise = new Promise((resolve, reject) => { const timeoutCallback = async (): Promise => { // We make sure that no more than REQUESTS_COUNT requests are sent simultaneously - // If thats the case, we re-schedule the timeout + // If that's the case, we re-schedule the timeout const timestamp = Date.now(); if (this.requestStack.length >= REQUESTS_COUNT) { const timestampToCheck = this.requestStack[this.requestStack.length - 1]; diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index be8d3d4fb636..eb9c15ce64b9 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -102,7 +102,7 @@ function fetchAll(url, filter = {}): Promise { } }); - // removing possible dublicates + // removing possible duplicates const obj = result.results.reduce((acc: Record, item: any) => { acc[item.id] = item; return acc; diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 47810637db37..369d0c9d5393 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -377,7 +377,7 @@ export function implementJob(Job: typeof JobClass): typeof JobClass { } if ('annotationsFilters' in searchParameters && 'generalFilters' in searchParameters) { - throw new ArgumentError('Both annotations filters and general fiters could not be used together'); + throw new ArgumentError('Both annotations filters and general filters could not be used together'); } if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { @@ -1046,7 +1046,7 @@ export function implementTask(Task: typeof TaskClass): typeof TaskClass { } if ('annotationsFilters' in searchParameters && 'generalFilters' in searchParameters) { - throw new ArgumentError('Both annotations filters and general fiters could not be used together'); + throw new ArgumentError('Both annotations filters and general filters could not be used together'); } if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { diff --git a/cvat-data/src/ts/3rdparty/README.md b/cvat-data/src/ts/3rdparty/README.md index 32ff0a20ab50..2bcd37af45b9 100644 --- a/cvat-data/src/ts/3rdparty/README.md +++ b/cvat-data/src/ts/3rdparty/README.md @@ -10,8 +10,8 @@ These files are from the [Broadway.js](https://github.com/mbebenita/Broadway) re Authors don't provide an npm package, so we need to store these components in our repository. We use this dependency to decode video chunks from a server and split them to frames on client side. -We need to run this package in node environent (for example for debug, or for running unit tests). -But there aren't any ways to do that (even with syntetic environment, provided for example by the package ``browser-env``). +We need to run this package in node environment (for example for debug, or for running unit tests). +But there aren't any ways to do that (even with synthetic environment, provided for example by the package ``browser-env``). For example there are issues with canvas using (webpack doesn't work with binary canvas package for node-js) and others. So, we have solved to write patch file for this library. It modifies source code a little to support our scenario of using. diff --git a/cvat-data/src/ts/unzip_imgs.worker.ts b/cvat-data/src/ts/unzip_imgs.worker.ts index 70d8299e7c38..4ca131a09955 100644 --- a/cvat-data/src/ts/unzip_imgs.worker.ts +++ b/cvat-data/src/ts/unzip_imgs.worker.ts @@ -34,7 +34,7 @@ onmessage = (e) => { .async('blob') .then((fileData) => { if (!errored) { - // do not need to read the rest of block if an error already occured + // do not need to read the rest of block if an error already occurred if (dimension === dimension2D) { createImageBitmap(fileData).then((img) => { postMessage({ diff --git a/cvat-ui/react_nginx.conf b/cvat-ui/react_nginx.conf index 5f1f4b48997a..6f9437ebbd75 100644 --- a/cvat-ui/react_nginx.conf +++ b/cvat-ui/react_nginx.conf @@ -1,7 +1,7 @@ server { root /usr/share/nginx/html; - # Disable server signature to make it slighty harder for + # Disable server signature to make it slightly harder for # attackers to find known vulnerabilities. See # https://datatracker.ietf.org/doc/html/rfc9110#name-server server_tokens off; diff --git a/cvat-ui/src/components/model-runner-modal/object-mapper.tsx b/cvat-ui/src/components/model-runner-modal/object-mapper.tsx index b1bb2b889d86..530d59bc54ac 100644 --- a/cvat-ui/src/components/model-runner-modal/object-mapper.tsx +++ b/cvat-ui/src/components/model-runner-modal/object-mapper.tsx @@ -40,7 +40,7 @@ function ObjectMapperComponent(props: Props): JSX.Element { const [rightValue, setRightValue] = useState(null); const setMappingWrapper = (updated: Props['defaultMapping']): void => { - // if we prefer useEffect instead of this approch + // if we prefer useEffect instead of this approach // component will be rerendered first with extras that depends on parent state // these extras will use outdated information in this case onUpdateMapping(updated); diff --git a/cvat-ui/src/components/tasks-page/automatic-annotation-progress.tsx b/cvat-ui/src/components/tasks-page/automatic-annotation-progress.tsx index 8dbb152ef9e0..8b2ecd507680 100644 --- a/cvat-ui/src/components/tasks-page/automatic-annotation-progress.tsx +++ b/cvat-ui/src/components/tasks-page/automatic-annotation-progress.tsx @@ -63,7 +63,7 @@ export default function AutomaticAnnotationProgress(props: Props): JSX.Element | return (<>Unknown status received); } - return <>Automatic annotation accomplisted; + return <>Automatic annotation accomplished; })()} diff --git a/cvat-ui/src/utils/environment.ts b/cvat-ui/src/utils/environment.ts index 15a6e4178b62..9f73417199c7 100644 --- a/cvat-ui/src/utils/environment.ts +++ b/cvat-ui/src/utils/environment.ts @@ -25,7 +25,7 @@ export function customWaViewHit(pageName?: string, queryString?: string, hashInf waHitFunction(pageName, queryString, hashInfo); } catch (error: any) { // eslint-disable-next-line - console.error(`Web analitycs hit function has failed. ${error.toString()}`); + console.error(`Web analytics hit function has failed. ${error.toString()}`); } } } diff --git a/cvat-ui/src/utils/opencv-wrapper/opencv-wrapper.ts b/cvat-ui/src/utils/opencv-wrapper/opencv-wrapper.ts index b2d045c32483..cc74252a824f 100644 --- a/cvat-ui/src/utils/opencv-wrapper/opencv-wrapper.ts +++ b/cvat-ui/src/utils/opencv-wrapper/opencv-wrapper.ts @@ -201,7 +201,7 @@ export class OpenCVWrapper { cv.findContours(expanded, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_NONE); for (let i = 0; i < contours.size(); i++) { const contour = contours.get(i); - // substract offset we created when copied source image + // subtract offset we created when copied source image jsContours.push(Array.from(contour.data32S as number[]).map((el) => el - 1)); contour.delete(); } From 6a2636207ef437976a194d954e55cbf59d3e950f Mon Sep 17 00:00:00 2001 From: Dmitrii Lavrukhin Date: Wed, 23 Oct 2024 11:36:18 +0400 Subject: [PATCH 013/163] Analytics access (#8509) Checkbox for granting access to analytics --- ...itrii.lavrukhin_analytics_bool_field_v3.md | 4 + cvat-core/src/server-response-types.ts | 1 + cvat-core/src/user.ts | 8 +- cvat-ui/src/components/header/header.tsx | 2 +- .../0086_profile_has_analytics_access.py | 35 ++++++ cvat/apps/engine/models.py | 15 ++- cvat/apps/engine/serializers.py | 15 ++- cvat/apps/engine/signals.py | 26 +++- cvat/apps/engine/tests/test_rest_api.py | 6 + cvat/apps/iam/admin.py | 13 ++ cvat/apps/iam/permissions.py | 18 ++- cvat/apps/log_viewer/permissions.py | 20 +++- cvat/apps/log_viewer/rules/analytics.rego | 11 +- .../rules/tests/configs/analytics.csv | 6 +- .../generators/analytics_test.gen.rego.py | 31 ++--- cvat/schema.yml | 3 + tests/python/rest_api/test_analytics.py | 15 ++- tests/python/shared/assets/cvat_db/data.json | 111 +++++++----------- tests/python/shared/assets/users.json | 21 ++++ tests/python/shared/fixtures/data.py | 19 ++- 20 files changed, 265 insertions(+), 115 deletions(-) create mode 100644 changelog.d/20241007_130122_dmitrii.lavrukhin_analytics_bool_field_v3.md create mode 100644 cvat/apps/engine/migrations/0086_profile_has_analytics_access.py diff --git a/changelog.d/20241007_130122_dmitrii.lavrukhin_analytics_bool_field_v3.md b/changelog.d/20241007_130122_dmitrii.lavrukhin_analytics_bool_field_v3.md new file mode 100644 index 000000000000..4c0448366503 --- /dev/null +++ b/changelog.d/20241007_130122_dmitrii.lavrukhin_analytics_bool_field_v3.md @@ -0,0 +1,4 @@ +### Added + +- Access to /analytics can now be granted + () diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index e28a6f9ec71a..af6cd760ed40 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -54,6 +54,7 @@ export interface SerializedUser { last_login?: string; date_joined?: string; email_verification_required: boolean; + has_analytics_access: boolean; } interface SerializedStorage { diff --git a/cvat-core/src/user.ts b/cvat-core/src/user.ts index 1b0eb5ecfec9..6d7366151fb4 100644 --- a/cvat-core/src/user.ts +++ b/cvat-core/src/user.ts @@ -1,5 +1,5 @@ // Copyright (C) 2019-2022 Intel Corporation -// Copyright (C) 2022 CVAT.ai Corporation +// Copyright (C) 2022-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -18,6 +18,7 @@ export default class User { public readonly isSuperuser: boolean; public readonly isActive: boolean; public readonly isVerified: boolean; + public readonly hasAnalyticsAccess: boolean; constructor(initialData: SerializedUser) { const data = { @@ -33,6 +34,7 @@ export default class User { is_superuser: null, is_active: null, email_verification_required: null, + has_analytics_access: null, }; for (const property in data) { @@ -80,6 +82,9 @@ export default class User { isVerified: { get: () => !data.email_verification_required, }, + hasAnalyticsAccess: { + get: () => data.has_analytics_access, + }, }), ); } @@ -98,6 +103,7 @@ export default class User { is_superuser: this.isSuperuser, is_active: this.isActive, email_verification_required: this.isVerified, + has_analytics_access: this.hasAnalyticsAccess, }; } } diff --git a/cvat-ui/src/components/header/header.tsx b/cvat-ui/src/components/header/header.tsx index 2621edddfa22..0feeae4be574 100644 --- a/cvat-ui/src/components/header/header.tsx +++ b/cvat-ui/src/components/header/header.tsx @@ -513,7 +513,7 @@ function HeaderComponent(props: Props): JSX.Element { Models ) : null} - {isAnalyticsPluginActive && user.isSuperuser ? ( + {isAnalyticsPluginActive && user.hasAnalyticsAccess ? ( - - )} - type='warning' - showIcon - /> - - ) : null} - - {autoSaveEnabled ? ( - - - Recommendation: - - - )} - type='warning' - showIcon - /> - - ) : null} - - 1. Select action + Select action
@@ -406,7 +376,7 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El }} > {actions.map( - (annotationFunction: BaseSingleFrameAction): JSX.Element => ( + (annotationFunction: BaseAction): JSX.Element => ( void; }): JSX.El
- {activeAction ? ( + {activeAction && !currentFrameAction ? ( <> - 2. Specify frames to apply the action + Specify frames to apply the action
- { - currentFrameAction ? ( - Running the action is only allowed on current frame - ) : ( - <> - Starting from frame - { - if (typeof value === 'number') { - dispatch(reducerActions.updateFrameFrom( - clamp( - Math.round(value), - jobInstance.startFrame, - frameTo, - ), - )); - } - }} - /> - up to frame - { - if (typeof value === 'number') { - dispatch(reducerActions.updateFrameTo( - clamp( - Math.round(value), - frameFrom, - jobInstance.stopFrame, - ), - )); - } - }} - /> - - - ) - } + Starting from frame + { + if (typeof value === 'number') { + dispatch(reducerActions.updateFrameFrom( + clamp( + Math.round(value), + jobInstance.startFrame, + frameTo, + ), + )); + } + }} + /> + up to frame + { + if (typeof value === 'number') { + dispatch(reducerActions.updateFrameTo( + clamp( + Math.round(value), + frameFrom, + jobInstance.stopFrame, + ), + )); + } + }} + />
@@ -534,7 +495,7 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El - 3. Setup action parameters + Setup action parameters
{Object.entries(activeAction.parameters) @@ -545,7 +506,7 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El onChange={(value: string) => { dispatch(reducerActions.updateActionParameter(name, value)); }} - defaultValue={defaultValue} + defaultValue={actionParameters[name] ?? defaultValue} type={type} values={values} /> @@ -593,28 +554,43 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El if (activeAction) { cancellationRef.current = false; dispatch(reducerActions.resetBeforeRun()); + const updateProgressWrapper = (_message: string, _progress: number): void => { + if (isMounted()) { + dispatch(reducerActions.updateProgress(_progress, _message)); + } + }; - core.actions.run( + const actionPromise = targetObjectState ? core.actions.call( + jobInstance, + activeAction, + actionParameters, + storage.getState().annotation.player.frame.number, + [targetObjectState], + updateProgressWrapper, + () => cancellationRef.current, + ) : core.actions.run( jobInstance, - [activeAction], - [actionParameters], + activeAction, + actionParameters, frameFrom, frameTo, storage.getState().annotation.annotations.filters, - (_message: string, _progress: number) => { - if (isMounted()) { - dispatch(reducerActions.updateProgress(_progress, _message)); - } - }, + updateProgressWrapper, () => cancellationRef.current, - ).then(() => { + ); + + actionPromise.then(() => { if (!cancellationRef.current) { canvasInstance.setup(frameData, []); storage.dispatch(fetchAnnotationsAsync()); } }).finally(() => { if (isMounted()) { - dispatch(reducerActions.resetAfterRun()); + if (targetObjectState !== null) { + onClose(); + } else { + dispatch(reducerActions.resetAfterRun()); + } } }).catch((error: unknown) => { if (error instanceof Error) { @@ -634,4 +610,19 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El ); } -export default React.memo(AnnotationsActionsModalContent); +const MemoizedAnnotationsActionsModalContent = React.memo(AnnotationsActionsModalContent); + +export function openAnnotationsActionModal(objectState?: ObjectState): void { + const div = window.document.createElement('div'); + window.document.body.append(div); + const root = createRoot(div); + root.render( + { + root.unmount(); + div.remove(); + }} + />, + ); +} diff --git a/cvat-ui/src/components/annotation-page/annotations-actions/styles.scss b/cvat-ui/src/components/annotation-page/annotations-actions/styles.scss index 787d5685ff37..b7eae1e50242 100644 --- a/cvat-ui/src/components/annotation-page/annotations-actions/styles.scss +++ b/cvat-ui/src/components/annotation-page/annotations-actions/styles.scss @@ -15,10 +15,6 @@ margin-top: $grid-unit-size * 2; } -.cvat-action-runner-info:not(:first-child) { - margin-top: $grid-unit-size * 2; -} - .cvat-action-runner-info { .ant-alert { text-align: justify; diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx index aee51de644c0..078da4b82669 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx @@ -37,6 +37,7 @@ interface Props { toForegroundShortcut: string; removeShortcut: string; sliceShortcut: string; + runAnnotationsActionShortcut: string; changeColor(color: string): void; changeLabel(label: any): void; copy(): void; @@ -47,6 +48,7 @@ interface Props { toBackground(): void; toForeground(): void; resetCuboidPerspective(): void; + runAnnotationAction(): void; edit(): void; slice(): void; } @@ -72,6 +74,7 @@ function ItemTopComponent(props: Props): JSX.Element { toForegroundShortcut, removeShortcut, sliceShortcut, + runAnnotationsActionShortcut, isGroundTruth, changeColor, changeLabel, @@ -83,6 +86,7 @@ function ItemTopComponent(props: Props): JSX.Element { toBackground, toForeground, resetCuboidPerspective, + runAnnotationAction, edit, slice, jobInstance, @@ -154,6 +158,7 @@ function ItemTopComponent(props: Props): JSX.Element { toForegroundShortcut, removeShortcut, sliceShortcut, + runAnnotationsActionShortcut, changeColor, copy, remove, @@ -166,6 +171,7 @@ function ItemTopComponent(props: Props): JSX.Element { setColorPickerVisible, edit, slice, + runAnnotationAction, })} > diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-menu.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-menu.tsx index 30b239d8187a..3a18f035f4a6 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-menu.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-menu.tsx @@ -8,6 +8,7 @@ import Button from 'antd/lib/button'; import { MenuProps } from 'antd/lib/menu'; import Icon, { LinkOutlined, CopyOutlined, BlockOutlined, RetweetOutlined, DeleteOutlined, EditOutlined, + FunctionOutlined, } from '@ant-design/icons'; import { @@ -34,6 +35,7 @@ interface Props { toBackgroundShortcut: string; toForegroundShortcut: string; removeShortcut: string; + runAnnotationsActionShortcut: string; changeColor(value: string): void; copy(): void; remove(): void; @@ -46,6 +48,7 @@ interface Props { setColorPickerVisible(visible: boolean): void; edit(): void; slice(): void; + runAnnotationAction(): void; jobInstance: Job; } @@ -232,6 +235,23 @@ function RemoveItem(props: ItemProps): JSX.Element { ); } +function RunAnnotationActionItem(props: ItemProps): JSX.Element { + const { toolProps } = props; + const { runAnnotationsActionShortcut, runAnnotationAction } = toolProps; + return ( + + + + ); +} + export default function ItemMenu(props: Props): MenuProps { const { readonly, shapeType, objectType, colorBy, jobInstance, @@ -249,6 +269,7 @@ export default function ItemMenu(props: Props): MenuProps { REMOVE_ITEM = 'remove_item', EDIT_MASK = 'edit_mask', SLICE_ITEM = 'slice_item', + RUN_ANNOTATION_ACTION = 'run_annotation_action', } const is2D = jobInstance.dimension === DimensionType.DIMENSION_2D; @@ -326,6 +347,13 @@ export default function ItemMenu(props: Props): MenuProps { }); } + if (!readonly) { + items.push({ + key: MenuKeys.RUN_ANNOTATION_ACTION, + label: , + }); + } + return { items, selectable: false, diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx index 30811abad1cd..7ae46a7a71a3 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx @@ -41,6 +41,7 @@ interface Props { changeLabel(label: any): void; changeColor(color: string): void; resetCuboidPerspective(): void; + runAnnotationAction(): void; edit(): void; slice(): void; } @@ -73,6 +74,7 @@ function ObjectItemComponent(props: Props): JSX.Element { changeLabel, changeColor, resetCuboidPerspective, + runAnnotationAction, edit, slice, jobInstance, @@ -121,6 +123,7 @@ function ObjectItemComponent(props: Props): JSX.Element { removeShortcut={normalizedKeyMap.DELETE_OBJECT_STANDARD_WORKSPACE} changeColorShortcut={normalizedKeyMap.CHANGE_OBJECT_COLOR} sliceShortcut={normalizedKeyMap.SWITCH_SLICE_MODE} + runAnnotationsActionShortcut={normalizedKeyMap.RUN_ANNOTATIONS_ACTION} changeLabel={changeLabel} changeColor={changeColor} copy={copy} @@ -133,6 +136,7 @@ function ObjectItemComponent(props: Props): JSX.Element { resetCuboidPerspective={resetCuboidPerspective} edit={edit} slice={slice} + runAnnotationAction={runAnnotationAction} /> {!!attributes.length && ( diff --git a/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx index f845b30233df..522f5f978b74 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx @@ -6,7 +6,6 @@ import React, { useCallback, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { useHistory } from 'react-router'; -import { createRoot } from 'react-dom/client'; import Modal from 'antd/lib/modal'; import Text from 'antd/lib/typography/Text'; import InputNumber from 'antd/lib/input-number'; @@ -22,7 +21,7 @@ import { MainMenuIcon } from 'icons'; import { Job, JobState } from 'cvat-core-wrapper'; import CVATTooltip from 'components/common/cvat-tooltip'; -import AnnotationsActionsModalContent from 'components/annotation-page/annotations-actions/annotations-actions-modal'; +import { openAnnotationsActionModal } from 'components/annotation-page/annotations-actions/annotations-actions-modal'; import { CombinedState } from 'reducers'; import { updateCurrentJobAsync, finishCurrentJobAsync, @@ -179,17 +178,7 @@ function AnnotationMenuComponent(): JSX.Element { key: Actions.RUN_ACTIONS, label: 'Run actions', onClick: () => { - const div = window.document.createElement('div'); - window.document.body.append(div); - const root = createRoot(div); - root.render( - { - root.unmount(); - div.remove(); - }} - />, - ); + openAnnotationsActionModal(); }, }); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx index 9cbb75bd75f2..362455a29fbf 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx @@ -20,6 +20,7 @@ import { import { ActiveControl, CombinedState, ColorBy, ShapeType, } from 'reducers'; +import { openAnnotationsActionModal } from 'components/annotation-page/annotations-actions/annotations-actions-modal'; import ObjectStateItemComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item'; import { getColor } from 'components/annotation-page/standard-workspace/objects-side-bar/shared'; import openCVWrapper from 'utils/opencv-wrapper/opencv-wrapper'; @@ -376,6 +377,11 @@ class ObjectItemContainer extends React.PureComponent { } }; + private runAnnotationAction = (): void => { + const { objectState } = this.props; + openAnnotationsActionModal(objectState); + }; + private commit(): void { const { objectState, readonly, updateState } = this.props; if (!readonly) { @@ -426,6 +432,7 @@ class ObjectItemContainer extends React.PureComponent { edit={this.edit} slice={this.slice} resetCuboidPerspective={this.resetCuboidPerspective} + runAnnotationAction={this.runAnnotationAction} /> ); } diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx index 16ccdc08bff7..5df7b556ff34 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx @@ -34,6 +34,7 @@ import { filterAnnotations } from 'utils/filter-annotations'; import { registerComponentShortcuts } from 'actions/shortcuts-actions'; import { ShortcutScope } from 'utils/enums'; import { subKeyMap } from 'utils/component-subkeymap'; +import { openAnnotationsActionModal } from 'components/annotation-page/annotations-actions/annotations-actions-modal'; interface OwnProps { readonly: boolean; @@ -148,6 +149,12 @@ const componentShortcuts = { sequences: ['ctrl+c'], scope: ShortcutScope.OBJECTS_SIDEBAR, }, + RUN_ANNOTATIONS_ACTION: { + name: 'Run annotations action', + description: 'Opens a dialog with annotations actions', + sequences: ['ctrl+e'], + scope: ShortcutScope.OBJECTS_SIDEBAR, + }, PROPAGATE_OBJECT: { name: 'Propagate object', description: 'Make a copy of the object on the following frames', @@ -588,6 +595,16 @@ class ObjectsListContainer extends React.PureComponent { copyShape(state); } }, + RUN_ANNOTATIONS_ACTION: () => { + const state = activatedState(true); + if (!readonly) { + if (state) { + openAnnotationsActionModal(state); + } else { + openAnnotationsActionModal(); + } + } + }, PROPAGATE_OBJECT: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedState(); diff --git a/cvat-ui/src/cvat-core-wrapper.ts b/cvat-ui/src/cvat-core-wrapper.ts index 52f71d6044bc..ba7b47fcfa54 100644 --- a/cvat-ui/src/cvat-core-wrapper.ts +++ b/cvat-ui/src/cvat-core-wrapper.ts @@ -26,8 +26,8 @@ import QualitySettings, { TargetMetric } from 'cvat-core/src/quality-settings'; import { FramesMetaData, FrameData } from 'cvat-core/src/frames'; import { ServerError, RequestError } from 'cvat-core/src/exceptions'; import { - ShapeType, LabelType, ModelKind, ModelProviders, - ModelReturnType, DimensionType, JobType, + ShapeType, ObjectType, LabelType, ModelKind, ModelProviders, + ModelReturnType, DimensionType, JobType, Source, JobStage, JobState, RQStatus, StorageLocation, } from 'cvat-core/src/enums'; import { Storage, StorageData } from 'cvat-core/src/storage'; @@ -41,7 +41,9 @@ import AnalyticsReport, { AnalyticsEntryViewType, AnalyticsEntry } from 'cvat-co import { Dumper } from 'cvat-core/src/annotation-formats'; import { Event } from 'cvat-core/src/event'; import { APIWrapperEnterOptions } from 'cvat-core/src/plugins'; -import BaseSingleFrameAction, { ActionParameterType, FrameSelectionType } from 'cvat-core/src/annotations-actions'; +import { BaseShapesAction } from 'cvat-core/src/annotations-actions/base-shapes-action'; +import { BaseCollectionAction } from 'cvat-core/src/annotations-actions/base-collection-action'; +import { ActionParameterType, BaseAction } from 'cvat-core/src/annotations-actions/base-action'; import { Request, RequestOperation } from 'cvat-core/src/request'; const cvat: CVATCore = _cvat; @@ -69,6 +71,8 @@ export { AnnotationGuide, Attribute, ShapeType, + Source, + ObjectType, LabelType, Storage, Webhook, @@ -89,7 +93,9 @@ export { JobStage, JobState, RQStatus, - BaseSingleFrameAction, + BaseAction, + BaseShapesAction, + BaseCollectionAction, QualityReport, QualityConflict, QualitySettings, @@ -105,7 +111,6 @@ export { Event, FrameData, ActionParameterType, - FrameSelectionType, Request, JobValidationLayout, TaskValidationLayout, diff --git a/serverless/deploy_cpu.sh b/serverless/deploy_cpu.sh index 03d6f17bad67..9f37ea020a6b 100755 --- a/serverless/deploy_cpu.sh +++ b/serverless/deploy_cpu.sh @@ -25,7 +25,10 @@ do echo "Deploying $func_rel_path function..." nuctl deploy --project-name cvat --path "$func_root" \ - --file "$func_config" --platform local + --file "$func_config" --platform local \ + --env CVAT_REDIS_HOST=$(echo ${CVAT_REDIS_INMEM_HOST:-cvat_redis_ondisk}) \ + --env CVAT_REDIS_PORT=$(echo ${CVAT_REDIS_INMEM_PORT:-6666}) \ + --env CVAT_REDIS_PASSWORD=$(echo ${CVAT_REDIS_INMEM_PASSWORD}) done nuctl get function --platform local diff --git a/serverless/deploy_gpu.sh b/serverless/deploy_gpu.sh index c813a8232ad4..9c8e1515b73b 100755 --- a/serverless/deploy_gpu.sh +++ b/serverless/deploy_gpu.sh @@ -17,7 +17,10 @@ do echo "Deploying $func_rel_path function..." nuctl deploy --project-name cvat --path "$func_root" \ - --file "$func_config" --platform local + --file "$func_config" --platform local \ + --env CVAT_REDIS_HOST=$(echo ${CVAT_REDIS_INMEM_HOST:-cvat_redis_ondisk}) \ + --env CVAT_REDIS_PORT=$(echo ${CVAT_REDIS_INMEM_PORT:-6666}) \ + --env CVAT_REDIS_PASSWORD=$(echo ${CVAT_REDIS_INMEM_PASSWORD}) done nuctl get function --platform local diff --git a/tests/cypress/e2e/features/annotations_actions.js b/tests/cypress/e2e/features/annotations_actions.js index cda91f9c33ba..55fe7542c680 100644 --- a/tests/cypress/e2e/features/annotations_actions.js +++ b/tests/cypress/e2e/features/annotations_actions.js @@ -86,47 +86,6 @@ context('Testing annotations actions workflow', () => { cy.closeAnnotationsActionsModal(); }); - - it('Recommendation to save the job appears if there are unsaved changes', () => { - cy.createRectangle({ - points: 'By 2 Points', - type: 'Shape', - labelName: taskPayload.labels[0].name, - firstX: 250, - firstY: 350, - secondX: 350, - secondY: 450, - }); - - cy.openAnnotationsActionsModal(); - cy.intercept(`/api/jobs/${jobID}/annotations?**action=create**`).as('createAnnotationsRequest'); - cy.get('.cvat-action-runner-save-job-recommendation').should('exist').and('be.visible').click(); - cy.wait('@createAnnotationsRequest').its('response.statusCode').should('equal', 200); - cy.get('.cvat-action-runner-save-job-recommendation').should('not.exist'); - - cy.closeAnnotationsActionsModal(); - }); - - it('Recommendation to disable automatic saving appears in modal if automatic saving is enabled', () => { - cy.openSettings(); - cy.contains('Workspace').click(); - cy.get('.cvat-workspace-settings-auto-save').within(() => { - cy.get('[type="checkbox"]').check(); - }); - cy.closeSettings(); - - cy.openAnnotationsActionsModal(); - cy.get('.cvat-action-runner-disable-autosave-recommendation').should('exist').and('be.visible').click(); - cy.get('.cvat-action-runner-disable-autosave-recommendation').should('not.exist'); - cy.closeAnnotationsActionsModal(); - - cy.openSettings(); - cy.contains('Workspace').click(); - cy.get('.cvat-workspace-settings-auto-save').within(() => { - cy.get('[type="checkbox"]').should('not.be.checked'); - }); - cy.closeSettings(); - }); }); describe('Test action: "Remove filtered shapes"', () => { @@ -374,7 +333,7 @@ context('Testing annotations actions workflow', () => { cy.goCheckFrameNumber(latestFrameNumber); cy.get('.cvat_canvas_shape').should('have.length', 1); - cy.saveJob('PUT', 200, 'saveJob'); + cy.saveJob('PATCH', 200, 'saveJob'); const exportAnnotation = { as: 'exportAnnotations', type: 'annotations', From 3cfa78b621a2359b46a1f47987620c1e64a636e0 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Mon, 25 Nov 2024 11:49:51 +0200 Subject: [PATCH 083/163] Fixed couple of issues with copy/paste a mask (#8728) --- changelog.d/20241121_013447_sekachev.bs.md | 4 ++++ changelog.d/20241121_013934_sekachev.bs.md | 4 ++++ cvat-canvas/src/typescript/masksHandler.ts | 4 ++++ .../annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx | 3 ++- 4 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20241121_013447_sekachev.bs.md create mode 100644 changelog.d/20241121_013934_sekachev.bs.md diff --git a/changelog.d/20241121_013447_sekachev.bs.md b/changelog.d/20241121_013447_sekachev.bs.md new file mode 100644 index 000000000000..47e7300bd071 --- /dev/null +++ b/changelog.d/20241121_013447_sekachev.bs.md @@ -0,0 +1,4 @@ +### Fixed + +- The error occurs when trying to copy/paste a mask on a video after opening the job + () diff --git a/changelog.d/20241121_013934_sekachev.bs.md b/changelog.d/20241121_013934_sekachev.bs.md new file mode 100644 index 000000000000..ce0410db76ea --- /dev/null +++ b/changelog.d/20241121_013934_sekachev.bs.md @@ -0,0 +1,4 @@ +### Fixed + +- Attributes do not get copied when copy/paste a mask + () diff --git a/cvat-canvas/src/typescript/masksHandler.ts b/cvat-canvas/src/typescript/masksHandler.ts index ca6e5e469a63..7f6a4e313fb3 100644 --- a/cvat-canvas/src/typescript/masksHandler.ts +++ b/cvat-canvas/src/typescript/masksHandler.ts @@ -404,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, diff --git a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx index df98f6b5c4cc..322a345efea7 100644 --- a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx @@ -653,7 +653,8 @@ class CanvasWrapperComponent extends React.PureComponent { const { state, duration } = event.detail; const isDrawnFromScratch = !state.label; - state.objectType = state.objectType || activeObjectType; + state.objectType = state.shapeType === ShapeType.MASK ? + ObjectType.SHAPE : state.objectType ?? activeObjectType; state.label = state.label || jobInstance.labels.filter((label: any) => label.id === activeLabelID)[0]; state.frame = frame; state.rotation = state.rotation || 0; From 3265e1f464963917edbe75e09a4080ffe6d7f964 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Mon, 25 Nov 2024 14:53:05 +0200 Subject: [PATCH 084/163] SDK/CLI: improve mask support in the auto-annotation functionality (#8724) While it is already possible to output mask shapes from AA functions, which the driver _will_ accept, it's not convenient to do so. Improve the practicalities of it in several ways: * Add `mask` and `polygon` helpers to the interface module. * Add a helper function to encode masks into the format CVAT expects. * Add a built-in torchvision-based instance segmentation function. * Add an equivalent of the `conv_mask_to_poly` parameter for Nuclio functions. Add another extra for the `masks` module, because NumPy is a fairly beefy dependency that most SDK users probably will not need (and conversely, I don't think we can implement `encode_mask` efficiently without using NumPy). --- .github/workflows/full.yml | 2 +- .github/workflows/main.yml | 2 +- changelog.d/20241120_143739_roman_aa_masks.md | 13 ++ cvat-cli/src/cvat_cli/_internal/commands.py | 8 ++ cvat-sdk/README.md | 9 +- cvat-sdk/cvat_sdk/auto_annotation/__init__.py | 19 +++ cvat-sdk/cvat_sdk/auto_annotation/driver.py | 20 ++- .../auto_annotation/functions/_torchvision.py | 26 ++++ .../functions/torchvision_detection.py | 20 +-- .../torchvision_instance_segmentation.py | 70 +++++++++ .../torchvision_keypoint_detection.py | 10 +- .../cvat_sdk/auto_annotation/interface.py | 25 ++++ cvat-sdk/cvat_sdk/masks.py | 44 ++++++ .../openapi-generator/setup.mustache | 3 +- site/content/en/docs/api_sdk/sdk/_index.md | 9 +- .../en/docs/api_sdk/sdk/auto-annotation.md | 23 ++- tests/python/cli/cmtp_function.py | 22 +++ tests/python/cli/test_cli.py | 22 +++ tests/python/requirements.txt | 4 +- tests/python/sdk/test_auto_annotation.py | 136 ++++++++++++++++++ tests/python/sdk/test_masks.py | 71 +++++++++ 21 files changed, 522 insertions(+), 36 deletions(-) create mode 100644 changelog.d/20241120_143739_roman_aa_masks.md create mode 100644 cvat-sdk/cvat_sdk/auto_annotation/functions/_torchvision.py create mode 100644 cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_instance_segmentation.py create mode 100644 cvat-sdk/cvat_sdk/masks.py create mode 100644 tests/python/cli/cmtp_function.py create mode 100644 tests/python/sdk/test_masks.py diff --git a/.github/workflows/full.yml b/.github/workflows/full.yml index e587e26aa1b8..e42380de5ead 100644 --- a/.github/workflows/full.yml +++ b/.github/workflows/full.yml @@ -156,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 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f4e3f11d1052..becca0218f94 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -166,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 diff --git a/changelog.d/20241120_143739_roman_aa_masks.md b/changelog.d/20241120_143739_roman_aa_masks.md new file mode 100644 index 000000000000..97422dfe6060 --- /dev/null +++ b/changelog.d/20241120_143739_roman_aa_masks.md @@ -0,0 +1,13 @@ +### Added + +- \[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) + () diff --git a/cvat-cli/src/cvat_cli/_internal/commands.py b/cvat-cli/src/cvat_cli/_internal/commands.py index f49416c843e5..324d427a64b8 100644 --- a/cvat-cli/src/cvat_cli/_internal/commands.py +++ b/cvat-cli/src/cvat_cli/_internal/commands.py @@ -476,6 +476,12 @@ def configure_parser(self, parser: argparse.ArgumentParser) -> None: default=None, ) + parser.add_argument( + "--conv-mask-to-poly", + action="store_true", + help="Convert mask shapes to polygon shapes", + ) + def execute( self, client: Client, @@ -487,6 +493,7 @@ def execute( 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) @@ -512,4 +519,5 @@ def execute( 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-sdk/README.md b/cvat-sdk/README.md index fa68c0e5d40d..89702c02abd4 100644 --- a/cvat-sdk/README.md +++ b/cvat-sdk/README.md @@ -20,7 +20,14 @@ To install a prebuilt package, run the following command in the terminal: pip install cvat-sdk ``` -To use the PyTorch adapter, request the `pytorch` extra: +To use the `cvat_sdk.masks` module, request the `masks` extra: + +```bash +pip install "cvat-sdk[masks]" +``` + +To use the PyTorch adapter or the built-in PyTorch-based auto-annotation functions, +request the `pytorch` extra: ```bash pip install "cvat-sdk[pytorch]" diff --git a/cvat-sdk/cvat_sdk/auto_annotation/__init__.py b/cvat-sdk/cvat_sdk/auto_annotation/__init__.py index e5dbdf9fcc42..adbb6007e125 100644 --- a/cvat-sdk/cvat_sdk/auto_annotation/__init__.py +++ b/cvat-sdk/cvat_sdk/auto_annotation/__init__.py @@ -10,8 +10,27 @@ keypoint, keypoint_spec, label_spec, + mask, + polygon, rectangle, shape, skeleton, skeleton_label_spec, ) + +__all__ = [ + "annotate_task", + "BadFunctionError", + "DetectionFunction", + "DetectionFunctionContext", + "DetectionFunctionSpec", + "keypoint_spec", + "keypoint", + "label_spec", + "mask", + "polygon", + "rectangle", + "shape", + "skeleton_label_spec", + "skeleton", +] diff --git a/cvat-sdk/cvat_sdk/auto_annotation/driver.py b/cvat-sdk/cvat_sdk/auto_annotation/driver.py index 175b96ab29b2..5ffdb36f5bee 100644 --- a/cvat-sdk/cvat_sdk/auto_annotation/driver.py +++ b/cvat-sdk/cvat_sdk/auto_annotation/driver.py @@ -99,9 +99,11 @@ def __init__( ds_labels: Sequence[models.ILabel], *, allow_unmatched_labels: bool, + conv_mask_to_poly: bool, ) -> None: self._logger = logger self._allow_unmatched_labels = allow_unmatched_labels + self._conv_mask_to_poly = conv_mask_to_poly ds_labels_by_name = {ds_label.name: ds_label for ds_label in ds_labels} @@ -217,6 +219,11 @@ def validate_and_remap(self, shapes: list[models.LabeledShapeRequest], ds_frame: if getattr(shape, "elements", None): raise BadFunctionError("function output non-skeleton shape with elements") + if shape.type.value == "mask" and self._conv_mask_to_poly: + raise BadFunctionError( + "function output mask shape despite conv_mask_to_poly=True" + ) + shapes[:] = new_shapes @@ -224,6 +231,7 @@ def validate_and_remap(self, shapes: list[models.LabeledShapeRequest], ds_frame: class _DetectionFunctionContextImpl(DetectionFunctionContext): frame_name: str conf_threshold: Optional[float] = None + conv_mask_to_poly: bool = False def annotate_task( @@ -235,6 +243,7 @@ def annotate_task( clear_existing: bool = False, allow_unmatched_labels: bool = False, conf_threshold: Optional[float] = None, + conv_mask_to_poly: bool = False, ) -> None: """ Downloads data for the task with the given ID, applies the given function to it @@ -268,7 +277,11 @@ def annotate_task( function that refer to this label are ignored. Otherwise, BadFunctionError is raised. The conf_threshold parameter must be None or a number between 0 and 1. It will be passed - to the function as the conf_threshold attribute of the context object. + to the AA function as the conf_threshold attribute of the context object. + + The conv_mask_to_poly parameter will be passed to the AA function as the conv_mask_to_poly + attribute of the context object. If it's true, and the AA function returns any mask shapes, + BadFunctionError will be raised. """ if pbar is None: @@ -286,6 +299,7 @@ def annotate_task( function.spec.labels, dataset.labels, allow_unmatched_labels=allow_unmatched_labels, + conv_mask_to_poly=conv_mask_to_poly, ) shapes = [] @@ -294,7 +308,9 @@ def annotate_task( for sample in pbar.iter(dataset.samples): frame_shapes = function.detect( _DetectionFunctionContextImpl( - frame_name=sample.frame_name, conf_threshold=conf_threshold + frame_name=sample.frame_name, + conf_threshold=conf_threshold, + conv_mask_to_poly=conv_mask_to_poly, ), sample.media.load_image(), ) diff --git a/cvat-sdk/cvat_sdk/auto_annotation/functions/_torchvision.py b/cvat-sdk/cvat_sdk/auto_annotation/functions/_torchvision.py new file mode 100644 index 000000000000..9fa88e0a7c07 --- /dev/null +++ b/cvat-sdk/cvat_sdk/auto_annotation/functions/_torchvision.py @@ -0,0 +1,26 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from functools import cached_property + +import torchvision.models + +import cvat_sdk.auto_annotation as cvataa + + +class TorchvisionFunction: + def __init__(self, model_name: str, weights_name: str = "DEFAULT", **kwargs) -> None: + weights_enum = torchvision.models.get_model_weights(model_name) + self._weights = weights_enum[weights_name] + self._transforms = self._weights.transforms() + self._model = torchvision.models.get_model(model_name, weights=self._weights, **kwargs) + self._model.eval() + + @cached_property + def spec(self) -> cvataa.DetectionFunctionSpec: + return cvataa.DetectionFunctionSpec( + labels=[ + cvataa.label_spec(cat, i) for i, cat in enumerate(self._weights.meta["categories"]) + ] + ) diff --git a/cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_detection.py b/cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_detection.py index 423db05adbcb..b16e4d8874ae 100644 --- a/cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_detection.py +++ b/cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_detection.py @@ -2,31 +2,15 @@ # # SPDX-License-Identifier: MIT -from functools import cached_property - import PIL.Image -import torchvision.models import cvat_sdk.auto_annotation as cvataa import cvat_sdk.models as models +from ._torchvision import TorchvisionFunction -class _TorchvisionDetectionFunction: - def __init__(self, model_name: str, weights_name: str = "DEFAULT", **kwargs) -> None: - weights_enum = torchvision.models.get_model_weights(model_name) - self._weights = weights_enum[weights_name] - self._transforms = self._weights.transforms() - self._model = torchvision.models.get_model(model_name, weights=self._weights, **kwargs) - self._model.eval() - - @cached_property - def spec(self) -> cvataa.DetectionFunctionSpec: - return cvataa.DetectionFunctionSpec( - labels=[ - cvataa.label_spec(cat, i) for i, cat in enumerate(self._weights.meta["categories"]) - ] - ) +class _TorchvisionDetectionFunction(TorchvisionFunction): def detect( self, context: cvataa.DetectionFunctionContext, image: PIL.Image.Image ) -> list[models.LabeledShapeRequest]: diff --git a/cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_instance_segmentation.py b/cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_instance_segmentation.py new file mode 100644 index 000000000000..6aa891811f5b --- /dev/null +++ b/cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_instance_segmentation.py @@ -0,0 +1,70 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import math +from collections.abc import Iterator + +import numpy as np +import PIL.Image +from skimage import measure +from torch import Tensor + +import cvat_sdk.auto_annotation as cvataa +import cvat_sdk.models as models +from cvat_sdk.masks import encode_mask + +from ._torchvision import TorchvisionFunction + + +def _is_positively_oriented(contour: np.ndarray) -> bool: + ys, xs = contour.T + + # This is the shoelace formula, except we only need the sign of the result, + # so we compare instead of subtracting. Compared to the typical formula, + # the sign is inverted, because the Y axis points downwards. + return np.sum(xs * np.roll(ys, -1)) < np.sum(ys * np.roll(xs, -1)) + + +def _generate_shapes( + context: cvataa.DetectionFunctionContext, box: Tensor, mask: Tensor, label: Tensor +) -> Iterator[models.LabeledShapeRequest]: + LEVEL = 0.5 + + if context.conv_mask_to_poly: + # Since we treat mask values of exactly LEVEL as true, we'd like them + # to also be considered high by find_contours. And for that, the level + # parameter must be slightly less than LEVEL. + contours = measure.find_contours(mask[0].detach().numpy(), level=math.nextafter(LEVEL, 0)) + + for contour in contours: + if len(contour) < 3 or _is_positively_oriented(contour): + continue + + contour = measure.approximate_polygon(contour, tolerance=2.5) + + yield cvataa.polygon(label.item(), contour[:, ::-1].ravel().tolist()) + + else: + yield cvataa.mask(label.item(), encode_mask(mask[0] >= LEVEL, box.tolist())) + + +class _TorchvisionInstanceSegmentationFunction(TorchvisionFunction): + def detect( + self, context: cvataa.DetectionFunctionContext, image: PIL.Image.Image + ) -> list[models.LabeledShapeRequest]: + conf_threshold = context.conf_threshold or 0 + results = self._model([self._transforms(image)]) + + return [ + shape + for result in results + for box, mask, label, score in zip( + result["boxes"], result["masks"], result["labels"], result["scores"] + ) + if score >= conf_threshold + for shape in _generate_shapes(context, box, mask, label) + ] + + +create = _TorchvisionInstanceSegmentationFunction diff --git a/cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_keypoint_detection.py b/cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_keypoint_detection.py index 0756b0b1738c..4d2250d61c35 100644 --- a/cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_keypoint_detection.py +++ b/cvat-sdk/cvat_sdk/auto_annotation/functions/torchvision_keypoint_detection.py @@ -5,20 +5,14 @@ from functools import cached_property import PIL.Image -import torchvision.models import cvat_sdk.auto_annotation as cvataa import cvat_sdk.models as models +from ._torchvision import TorchvisionFunction -class _TorchvisionKeypointDetectionFunction: - def __init__(self, model_name: str, weights_name: str = "DEFAULT", **kwargs) -> None: - weights_enum = torchvision.models.get_model_weights(model_name) - self._weights = weights_enum[weights_name] - self._transforms = self._weights.transforms() - self._model = torchvision.models.get_model(model_name, weights=self._weights, **kwargs) - self._model.eval() +class _TorchvisionKeypointDetectionFunction(TorchvisionFunction): @cached_property def spec(self) -> cvataa.DetectionFunctionSpec: return cvataa.DetectionFunctionSpec( diff --git a/cvat-sdk/cvat_sdk/auto_annotation/interface.py b/cvat-sdk/cvat_sdk/auto_annotation/interface.py index 47e944a1de84..f95cb50b4f2d 100644 --- a/cvat-sdk/cvat_sdk/auto_annotation/interface.py +++ b/cvat-sdk/cvat_sdk/auto_annotation/interface.py @@ -68,6 +68,16 @@ def conf_threshold(self) -> Optional[float]: If the function is not able to estimate confidence levels, it can ignore this value. """ + @property + @abc.abstractmethod + def conv_mask_to_poly(self) -> bool: + """ + If this is true, the function must convert any mask shapes to polygon shapes + before returning them. + + If the function does not return any mask shapes, then it can ignore this value. + """ + class DetectionFunction(Protocol): """ @@ -168,6 +178,21 @@ def rectangle(label_id: int, points: Sequence[float], **kwargs) -> models.Labele return shape(label_id, type="rectangle", points=points, **kwargs) +def polygon(label_id: int, points: Sequence[float], **kwargs) -> models.LabeledShapeRequest: + """Helper factory function for LabeledShapeRequest with frame=0 and type="polygon".""" + return shape(label_id, type="polygon", points=points, **kwargs) + + +def mask(label_id: int, points: Sequence[float], **kwargs) -> models.LabeledShapeRequest: + """ + Helper factory function for LabeledShapeRequest with frame=0 and type="mask". + + It's recommended to use the cvat.masks.encode_mask function to build the + points argument. + """ + return shape(label_id, type="mask", points=points, **kwargs) + + def skeleton( label_id: int, elements: Sequence[models.SubLabeledShapeRequest], **kwargs ) -> models.LabeledShapeRequest: diff --git a/cvat-sdk/cvat_sdk/masks.py b/cvat-sdk/cvat_sdk/masks.py new file mode 100644 index 000000000000..f623aec7d043 --- /dev/null +++ b/cvat-sdk/cvat_sdk/masks.py @@ -0,0 +1,44 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import math +from collections.abc import Sequence + +import numpy as np +from numpy.typing import ArrayLike + + +def encode_mask(bitmap: ArrayLike, /, bbox: Sequence[float]) -> list[float]: + """ + Encodes an image mask into an array of numbers suitable for the "points" + attribute of a LabeledShapeRequest object of type "mask". + + bitmap must be a boolean array of shape (H, W), where H is the height and + W is the width of the image that the mask applies to. + + bbox must have the form [x1, y1, x2, y2], where (0, 0) <= (x1, y1) < (x2, y2) <= (W, H). + The mask will be limited to points between (x1, y1) and (x2, y2). + """ + + bitmap = np.asanyarray(bitmap) + if bitmap.ndim != 2: + raise ValueError("bitmap must have 2 dimensions") + if bitmap.dtype != np.bool_: + raise ValueError("bitmap must have boolean items") + + x1, y1 = map(math.floor, bbox[0:2]) + x2, y2 = map(math.ceil, bbox[2:4]) + + if not (0 <= x1 < x2 <= bitmap.shape[1] and 0 <= y1 < y2 <= bitmap.shape[0]): + raise ValueError("bbox has invalid coordinates") + + flat = bitmap[y1:y2, x1:x2].ravel() + + (run_indices,) = np.diff(flat, prepend=[not flat[0]], append=[not flat[-1]]).nonzero() + if flat[0]: + run_lengths = np.diff(run_indices, prepend=[0]) + else: + run_lengths = np.diff(run_indices) + + return run_lengths.tolist() + [x1, y1, x2 - 1, y2 - 1] diff --git a/cvat-sdk/gen/templates/openapi-generator/setup.mustache b/cvat-sdk/gen/templates/openapi-generator/setup.mustache index eb89f5d20554..e0379cabd06e 100644 --- a/cvat-sdk/gen/templates/openapi-generator/setup.mustache +++ b/cvat-sdk/gen/templates/openapi-generator/setup.mustache @@ -77,7 +77,8 @@ setup( python_requires="{{{generatorLanguageVersion}}}", install_requires=BASE_REQUIREMENTS, extras_require={ - "pytorch": ['torch', 'torchvision'], + "masks": ["numpy>=2"], + "pytorch": ['torch', 'torchvision', 'scikit-image>=0.24', 'cvat_sdk[masks]'], }, package_dir={"": "."}, packages=find_packages(include=["cvat_sdk*"]), diff --git a/site/content/en/docs/api_sdk/sdk/_index.md b/site/content/en/docs/api_sdk/sdk/_index.md index e9683583ab0e..e855dadd979f 100644 --- a/site/content/en/docs/api_sdk/sdk/_index.md +++ b/site/content/en/docs/api_sdk/sdk/_index.md @@ -42,7 +42,14 @@ To install an [official release of CVAT SDK](https://pypi.org/project/cvat-sdk/) pip install cvat-sdk ``` -To use the PyTorch adapter, request the `pytorch` extra: +To use the `cvat_sdk.masks` module, request the `masks` extra: + +```bash +pip install "cvat-sdk[masks]" +``` + +To use the PyTorch adapter or the built-in PyTorch-based auto-annotation functions, +request the `pytorch` extra: ```bash pip install "cvat-sdk[pytorch]" diff --git a/site/content/en/docs/api_sdk/sdk/auto-annotation.md b/site/content/en/docs/api_sdk/sdk/auto-annotation.md index f97759efd175..d8401955da7f 100644 --- a/site/content/en/docs/api_sdk/sdk/auto-annotation.md +++ b/site/content/en/docs/api_sdk/sdk/auto-annotation.md @@ -181,10 +181,23 @@ The following helpers are available for use in `detect`: | Name | Model type | Fixed attributes | |-------------|--------------------------|-------------------------------| | `shape` | `LabeledShapeRequest` | `frame=0` | +| `mask` | `LabeledShapeRequest` | `frame=0`, `type="mask"` | +| `polygon` | `LabeledShapeRequest` | `frame=0`, `type="polygon"` | | `rectangle` | `LabeledShapeRequest` | `frame=0`, `type="rectangle"` | | `skeleton` | `LabeledShapeRequest` | `frame=0`, `type="skeleton"` | | `keypoint` | `SubLabeledShapeRequest` | `frame=0`, `type="points"` | +For `mask`, it is recommended to create the points list using +the `cvat.masks.encode_mask` function, which will convert a bitmap into a +list in the format that CVAT expects. For example: + +```python +cvataa.mask(my_label, encode_mask( + my_mask, # boolean 2D array, same size as the input image + [x1, y1, x2, y2], # top left and bottom right coordinates of the mask +)) +``` + ## Auto-annotation driver The `annotate_task` function uses an AA function to annotate a CVAT task. @@ -257,10 +270,18 @@ The `create` function accepts the following parameters: It also accepts arbitrary additional parameters, which are passed directly to the model constructor. +### `cvat_sdk.auto_annotation.functions.torchvision_instance_segmentation` + +This AA function is analogous to `torchvision_detection`, +except it uses torchvision's instance segmentation models and produces mask +or polygon annotations (depending on the value of `conv_mask_to_poly`). + +Refer to that function's description for usage instructions and parameter information. + ### `cvat_sdk.auto_annotation.functions.torchvision_keypoint_detection` This AA function is analogous to `torchvision_detection`, except it uses torchvision's keypoint detection models and produces skeleton annotations. Keypoints which the model marks as invisible will be marked as occluded in CVAT. -Refer to the previous section for usage instructions and parameter information. +Refer to that function's description for usage instructions and parameter information. diff --git a/tests/python/cli/cmtp_function.py b/tests/python/cli/cmtp_function.py new file mode 100644 index 000000000000..2ae5cb26f663 --- /dev/null +++ b/tests/python/cli/cmtp_function.py @@ -0,0 +1,22 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import cvat_sdk.auto_annotation as cvataa +import cvat_sdk.models as models +import PIL.Image + +spec = cvataa.DetectionFunctionSpec( + labels=[ + cvataa.label_spec("car", 0), + ], +) + + +def detect( + context: cvataa.DetectionFunctionContext, image: PIL.Image.Image +) -> list[models.LabeledShapeRequest]: + if context.conv_mask_to_poly: + return [cvataa.polygon(0, [0, 0, 0, 1, 1, 1])] + else: + return [cvataa.mask(0, [1, 0, 0, 0, 0])] diff --git a/tests/python/cli/test_cli.py b/tests/python/cli/test_cli.py index a039fd3744bc..f57775ca67ab 100644 --- a/tests/python/cli/test_cli.py +++ b/tests/python/cli/test_cli.py @@ -361,3 +361,25 @@ def test_auto_annotate_with_threshold(self, fxt_new_task: Task): annotations = fxt_new_task.get_annotations() assert annotations.shapes[0].points[0] == 0.75 + + def test_auto_annotate_with_cmtp(self, fxt_new_task: Task): + self.run_cli( + "auto-annotate", + str(fxt_new_task.id), + f"--function-module={__package__}.cmtp_function", + "--clear-existing", + ) + + annotations = fxt_new_task.get_annotations() + assert annotations.shapes[0].type.value == "mask" + + self.run_cli( + "auto-annotate", + str(fxt_new_task.id), + f"--function-module={__package__}.cmtp_function", + "--clear-existing", + "--conv-mask-to-poly", + ) + + annotations = fxt_new_task.get_annotations() + assert annotations.shapes[0].type.value == "polygon" diff --git a/tests/python/requirements.txt b/tests/python/requirements.txt index 6ef44c0f5edb..5dfad3d6f7fb 100644 --- a/tests/python/requirements.txt +++ b/tests/python/requirements.txt @@ -4,9 +4,9 @@ pytest-cases==3.6.13 pytest-timeout==2.1.0 pytest-cov==4.1.0 requests==2.32.2 -deepdiff==5.6.0 +deepdiff==7.0.1 boto3==1.17.61 Pillow==10.3.0 python-dateutil==2.8.2 pyyaml==6.0.0 -numpy==1.22.0 \ No newline at end of file +numpy==2.0.0 diff --git a/tests/python/sdk/test_auto_annotation.py b/tests/python/sdk/test_auto_annotation.py index 6fa96a5843f4..ff7302c1d9c5 100644 --- a/tests/python/sdk/test_auto_annotation.py +++ b/tests/python/sdk/test_auto_annotation.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: MIT import io +import math from logging import Logger from pathlib import Path from types import SimpleNamespace as namespace @@ -307,6 +308,39 @@ def detect( conf_threshold=bad_threshold, ) + def test_conv_mask_to_poly(self): + spec = cvataa.DetectionFunctionSpec( + labels=[ + cvataa.label_spec("car", 123), + ], + ) + + received_cmtp = None + + def detect(context, image: PIL.Image.Image) -> list[models.LabeledShapeRequest]: + nonlocal received_cmtp + received_cmtp = context.conv_mask_to_poly + return [cvataa.mask(123, [1, 0, 0, 0, 0])] + + cvataa.annotate_task( + self.client, + self.task.id, + namespace(spec=spec, detect=detect), + conv_mask_to_poly=False, + ) + + assert received_cmtp is False + + with pytest.raises(cvataa.BadFunctionError, match=".*conv_mask_to_poly.*"): + cvataa.annotate_task( + self.client, + self.task.id, + namespace(spec=spec, detect=detect), + conv_mask_to_poly=True, + ) + + assert received_cmtp is True + def _test_bad_function_spec(self, spec: cvataa.DetectionFunctionSpec, exc_match: str) -> None: def detect(context, image): assert False @@ -626,6 +660,60 @@ def fake_get_detection_model(name: str, weights, test_param): return FakeTorchvisionDetector(label_id=car_label_id) + class FakeTorchvisionInstanceSegmenter(nn.Module): + def __init__(self, label_id: int) -> None: + super().__init__() + self._label_id = label_id + + def forward(self, images: list[torch.Tensor]) -> list[dict]: + assert isinstance(images, list) + assert all(isinstance(t, torch.Tensor) for t in images) + + def make_box(im, a1, a2): + return [im.shape[2] * a1, im.shape[1] * a1, im.shape[2] * a2, im.shape[1] * a2] + + def make_mask(im, a1, a2): + # creates a rectangular mask with a hole + mask = torch.full((1, im.shape[1], im.shape[2]), 0.49) + mask[ + 0, + math.ceil(im.shape[1] * a1) : math.floor(im.shape[1] * a2), + math.ceil(im.shape[2] * a1) : math.floor(im.shape[2] * a2), + ] = 0.5 + mask[ + 0, + math.ceil(im.shape[1] * a1) + 3 : math.floor(im.shape[1] * a2) - 3, + math.ceil(im.shape[2] * a1) + 3 : math.floor(im.shape[2] * a2) - 3, + ] = 0.49 + return mask + + return [ + { + "labels": torch.tensor([self._label_id, self._label_id]), + "boxes": torch.tensor( + [ + make_box(im, 1 / 6, 1 / 3), + make_box(im, 2 / 3, 5 / 6), + ] + ), + "masks": torch.stack( + [ + make_mask(im, 1 / 6, 1 / 3), + make_mask(im, 2 / 3, 5 / 6), + ] + ), + "scores": torch.tensor([0.75, 0.74]), + } + for im in images + ] + + def fake_get_instance_segmentation_model(name: str, weights, test_param): + assert test_param == "expected_value" + + car_label_id = weights.meta["categories"].index("car") + + return FakeTorchvisionInstanceSegmenter(label_id=car_label_id) + class FakeTorchvisionKeypointDetector(nn.Module): def __init__(self, label_id: int, keypoint_names: list[str]) -> None: super().__init__() @@ -723,6 +811,54 @@ def test_torchvision_detection(self, monkeypatch: pytest.MonkeyPatch): assert annotations.shapes[0].type.value == "rectangle" assert annotations.shapes[0].points == [1, 2, 3, 4] + def test_torchvision_instance_segmentation(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(torchvision_models, "get_model", fake_get_instance_segmentation_model) + + import cvat_sdk.auto_annotation.functions.torchvision_instance_segmentation as tis + from cvat_sdk.masks import encode_mask + + cvataa.annotate_task( + self.client, + self.task.id, + tis.create("maskrcnn_resnet50_fpn_v2", "COCO_V1", test_param="expected_value"), + allow_unmatched_labels=True, + conf_threshold=0.75, + ) + + annotations = self.task.get_annotations() + + assert len(annotations.shapes) == 1 + assert self.task_labels_by_id[annotations.shapes[0].label_id].name == "car" + + expected_bitmap = torch.zeros((100, 100), dtype=torch.bool) + expected_bitmap[17:33, 17:33] = True + expected_bitmap[20:30, 20:30] = False + + assert annotations.shapes[0].type.value == "mask" + assert annotations.shapes[0].points == encode_mask(expected_bitmap, [16, 16, 34, 34]) + + cvataa.annotate_task( + self.client, + self.task.id, + tis.create("maskrcnn_resnet50_fpn_v2", "COCO_V1", test_param="expected_value"), + allow_unmatched_labels=True, + conf_threshold=0.75, + conv_mask_to_poly=True, + clear_existing=True, + ) + + annotations = self.task.get_annotations() + + assert len(annotations.shapes) == 1 + assert self.task_labels_by_id[annotations.shapes[0].label_id].name == "car" + assert annotations.shapes[0].type.value == "polygon" + + # We shouldn't rely on the exact result of polygon conversion, + # since it depends on a 3rd-party library. Instead, we'll just + # check that all points are within the expected area. + for x, y in zip(*[iter(annotations.shapes[0].points)] * 2): + assert expected_bitmap[round(y), round(x)] + def test_torchvision_keypoint_detection(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(torchvision_models, "get_model", fake_get_keypoint_detection_model) diff --git a/tests/python/sdk/test_masks.py b/tests/python/sdk/test_masks.py new file mode 100644 index 000000000000..46e8b9f214cc --- /dev/null +++ b/tests/python/sdk/test_masks.py @@ -0,0 +1,71 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import pytest + +try: + import numpy as np + from cvat_sdk.masks import encode_mask + +except ModuleNotFoundError as e: + if e.name.split(".")[0] != "numpy": + raise + + encode_mask = None + + +@pytest.mark.skipif(encode_mask is None, reason="NumPy is not installed") +class TestMasks: + def test_encode_mask(self): + bitmap = np.array( + [ + np.fromstring("0 0 1 1 1 0", sep=" "), + np.fromstring("0 1 1 0 0 0", sep=" "), + ], + dtype=np.bool_, + ) + bbox = [2.9, 0.9, 4.1, 1.1] # will get rounded to [2, 0, 5, 2] + + # There's slightly different logic for when the cropped mask starts with + # 0 and 1, so test both. + # This one starts with 1: + # 111 + # 100 + + assert encode_mask(bitmap, bbox) == [0, 4, 2, 2, 0, 4, 1] + + bbox = [1, 0, 5, 2] + + # This one starts with 0: + # 0111 + # 1100 + + assert encode_mask(bitmap, bbox) == [1, 5, 2, 1, 0, 4, 1] + + # Edge case: full image + bbox = [0, 0, 6, 2] + assert encode_mask(bitmap, bbox) == [2, 3, 2, 2, 3, 0, 0, 5, 1] + + def test_encode_mask_invalid_dim(self): + with pytest.raises(ValueError, match="bitmap must have 2 dimensions"): + encode_mask([True], [0, 0, 1, 1]) + + def test_encode_mask_invalid_dtype(self): + with pytest.raises(ValueError, match="bitmap must have boolean items"): + encode_mask([[1]], [0, 0, 1, 1]) + + @pytest.mark.parametrize( + "bbox", + [ + [-0.1, 0, 1, 1], + [0, -0.1, 1, 1], + [0, 0, 1.1, 1], + [0, 0, 1, 1.1], + [1, 0, 0, 1], + [0, 1, 1, 0], + ], + ) + def test_encode_mask_invalid_bbox(self, bbox): + with pytest.raises(ValueError, match="bbox has invalid coordinates"): + encode_mask([[True]], bbox) From d79e0563f6dfd6f76c0b78c878f3c1ff4b8a576f Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 25 Nov 2024 16:50:22 +0300 Subject: [PATCH 085/163] Update Datumaro format (#7125) ### Motivation and context Fixes https://github.com/opencv/cvat/issues/5424 Fixes https://github.com/opencv/cvat/issues/7375 Fixes https://github.com/cvat-ai/cvat/issues/8700 Depends on https://github.com/cvat-ai/datumaro/pull/34 This PR improves quality of life when using Datumaro format. - Added support for direct .json uploading of annotations, similarly to the COCO and CVAT formats - Added image metadata when exporting in the Datumaro format without images - For related images in 3d tasks, datumaro export without images will include only the basenames (before: absolute server paths were exported) - Refactored `conv_mask_to_poly` uses to avoid code and logic duplication (will be in another PR) ### How has this been tested? Unit tests ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/opencv/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/opencv/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/opencv/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/opencv/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/opencv/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced support for direct .json file import in Datumaro format. - **Bug Fixes** - Fixed an issue where exporting without images in Datumaro format now correctly includes image information. - **Refactor** - Renamed classes and methods in dataset management to support a broader range of media types, enhancing the system's flexibility. - **Tests** - Added new tests to verify the behavior of importing and exporting in Datumaro format, ensuring robustness in dataset handling. --- ...231110_175126_mzhiltso_update_dm_format.md | 9 + cvat/apps/dataset_manager/bindings.py | 195 +++++++++++------- cvat/apps/dataset_manager/formats/coco.py | 16 +- cvat/apps/dataset_manager/formats/cvat.py | 17 +- cvat/apps/dataset_manager/formats/datumaro.py | 54 ++--- tests/python/rest_api/test_projects.py | 71 ++++++- tests/python/rest_api/test_tasks.py | 83 ++++++++ tests/python/rest_api/utils.py | 8 +- 8 files changed, 332 insertions(+), 121 deletions(-) create mode 100644 changelog.d/20231110_175126_mzhiltso_update_dm_format.md diff --git a/changelog.d/20231110_175126_mzhiltso_update_dm_format.md b/changelog.d/20231110_175126_mzhiltso_update_dm_format.md new file mode 100644 index 000000000000..2aed7d7c8759 --- /dev/null +++ b/changelog.d/20231110_175126_mzhiltso_update_dm_format.md @@ -0,0 +1,9 @@ +### Added + +- Support for direct .json file import in Datumaro format + () + +### Fixed + +- Export without images in Datumaro format should include image info + () diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 1c70520a7090..9d073ca1bc73 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -20,9 +20,6 @@ import defusedxml.ElementTree as ET import rq from attr import attrib, attrs -from datumaro.components.media import PointCloud -from datumaro.components.environment import Environment -from datumaro.components.extractor import Importer from datumaro.components.format_detection import RejectionReason from django.db.models import QuerySet from django.utils import timezone @@ -1463,19 +1460,22 @@ def add_task(self, task, files): self._project_annotation.add_task(task, files, self) @attrs(frozen=True, auto_attribs=True) -class ImageSource: +class MediaSource: db_task: Task - is_video: bool = attrib(kw_only=True) -class ImageProvider: - def __init__(self, sources: Dict[int, ImageSource]) -> None: + @property + def is_video(self) -> bool: + return self.db_task.mode == 'interpolation' + +class MediaProvider: + def __init__(self, sources: Dict[int, MediaSource]) -> None: self._sources = sources def unload(self) -> None: pass -class ImageProvider2D(ImageProvider): - def __init__(self, sources: Dict[int, ImageSource]) -> None: +class MediaProvider2D(MediaProvider): + def __init__(self, sources: Dict[int, MediaSource]) -> None: super().__init__(sources) self._current_source_id = None self._frame_provider = None @@ -1483,7 +1483,7 @@ def __init__(self, sources: Dict[int, ImageSource]) -> None: def unload(self) -> None: self._unload_source() - def get_image_for_frame(self, source_id: int, frame_index: int, **image_kwargs): + def get_media_for_frame(self, source_id: int, frame_index: int, **image_kwargs) -> dm.Image: source = self._sources[source_id] if source.is_video: @@ -1510,7 +1510,7 @@ def image_loader(_): return dm.ByteImage(data=image_loader, **image_kwargs) - def _load_source(self, source_id: int, source: ImageSource) -> None: + def _load_source(self, source_id: int, source: MediaSource) -> None: if self._current_source_id == source_id: return @@ -1525,8 +1525,8 @@ def _unload_source(self) -> None: self._current_source_id = None -class ImageProvider3D(ImageProvider): - def __init__(self, sources: Dict[int, ImageSource]) -> None: +class MediaProvider3D(MediaProvider): + def __init__(self, sources: Dict[int, MediaSource]) -> None: super().__init__(sources) self._images_per_source = { source_id: { @@ -1536,7 +1536,7 @@ def __init__(self, sources: Dict[int, ImageSource]) -> None: for source_id, source in sources.items() } - def get_image_for_frame(self, source_id: int, frame_id: int, **image_kwargs): + def get_media_for_frame(self, source_id: int, frame_id: int, **image_kwargs) -> dm.PointCloud: source = self._sources[source_id] point_cloud_path = osp.join( @@ -1546,17 +1546,17 @@ def get_image_for_frame(self, source_id: int, frame_id: int, **image_kwargs): image = self._images_per_source[source_id][frame_id] related_images = [ - path + dm.Image(path=path) for rf in image.related_files.all() for path in [osp.realpath(str(rf.path))] if osp.isfile(path) ] - return point_cloud_path, related_images + return dm.PointCloud(point_cloud_path, extra_images=related_images) -IMAGE_PROVIDERS_BY_DIMENSION = { - DimensionType.DIM_3D: ImageProvider3D, - DimensionType.DIM_2D: ImageProvider2D, +MEDIA_PROVIDERS_BY_DIMENSION: Dict[DimensionType, MediaProvider] = { + DimensionType.DIM_3D: MediaProvider3D, + DimensionType.DIM_2D: MediaProvider2D, } class CVATDataExtractorMixin: @@ -1565,14 +1565,14 @@ def __init__(self, *, ): self.convert_annotations = convert_annotations or convert_cvat_anno_to_dm - self._image_provider: Optional[ImageProvider] = None + self._media_provider: Optional[MediaProvider] = None def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback) -> None: - if self._image_provider: - self._image_provider.unload() + if self._media_provider: + self._media_provider.unload() def categories(self) -> dict: raise NotImplementedError() @@ -1639,7 +1639,7 @@ def __init__( instance_meta = instance_data.meta[instance_data.META_FIELD] dm.SourceExtractor.__init__( self, - media_type=dm.Image if dimension == DimensionType.DIM_2D else PointCloud, + media_type=dm.Image if dimension == DimensionType.DIM_2D else dm.PointCloud, subset=instance_meta['subset'], ) CVATDataExtractorMixin.__init__(self, **kwargs) @@ -1648,7 +1648,6 @@ def __init__( self._user = self._load_user_info(instance_meta) if dimension == DimensionType.DIM_3D else {} self._dimension = dimension self._format_type = format_type - dm_items = [] is_video = instance_meta['mode'] == 'interpolation' ext = '' @@ -1663,46 +1662,61 @@ def __init__( else: assert False - self._image_provider = IMAGE_PROVIDERS_BY_DIMENSION[dimension]( - {0: ImageSource(db_task, is_video=is_video)} + self._media_provider = MEDIA_PROVIDERS_BY_DIMENSION[dimension]( + {0: MediaSource(db_task)} ) + dm_items: List[dm.DatasetItem] = [] for frame_data in instance_data.group_by_frame(include_empty=True): - image_args = { - 'path': frame_data.name + ext, - 'size': (frame_data.height, frame_data.width), - } - + dm_media_args = { 'path': frame_data.name + ext } if dimension == DimensionType.DIM_3D: - dm_image = self._image_provider.get_image_for_frame(0, frame_data.id, **image_args) - elif include_images: - dm_image = self._image_provider.get_image_for_frame(0, frame_data.idx, **image_args) + dm_media: dm.PointCloud = self._media_provider.get_media_for_frame( + 0, frame_data.id, **dm_media_args + ) + + if not include_images: + dm_media_args["extra_images"] = [ + dm.Image(path=osp.basename(image.path)) + for image in dm_media.extra_images + ] + dm_media = dm.PointCloud(**dm_media_args) else: - dm_image = dm.Image(**image_args) + dm_media_args['size'] = (frame_data.height, frame_data.width) + if include_images: + dm_media: dm.Image = self._media_provider.get_media_for_frame( + 0, frame_data.idx, **dm_media_args + ) + else: + dm_media = dm.Image(**dm_media_args) + dm_anno = self._read_cvat_anno(frame_data, instance_meta['labels']) + dm_attributes = {'frame': frame_data.frame} + if dimension == DimensionType.DIM_2D: dm_item = dm.DatasetItem( - id=osp.splitext(frame_data.name)[0], - annotations=dm_anno, media=dm_image, - subset=frame_data.subset, - attributes={'frame': frame_data.frame - }) + id=osp.splitext(frame_data.name)[0], + subset=frame_data.subset, + annotations=dm_anno, + media=dm_media, + attributes=dm_attributes, + ) elif dimension == DimensionType.DIM_3D: - attributes = {'frame': frame_data.frame} if format_type == "sly_pointcloud": - attributes["name"] = self._user["name"] - attributes["createdAt"] = self._user["createdAt"] - attributes["updatedAt"] = self._user["updatedAt"] - attributes["labels"] = [] + dm_attributes["name"] = self._user["name"] + dm_attributes["createdAt"] = self._user["createdAt"] + dm_attributes["updatedAt"] = self._user["updatedAt"] + dm_attributes["labels"] = [] for (idx, (_, label)) in enumerate(instance_meta['labels']): - attributes["labels"].append({"label_id": idx, "name": label["name"], "color": label["color"], "type": label["type"]}) - attributes["track_id"] = -1 + dm_attributes["labels"].append({"label_id": idx, "name": label["name"], "color": label["color"], "type": label["type"]}) + dm_attributes["track_id"] = -1 dm_item = dm.DatasetItem( id=osp.splitext(osp.split(frame_data.name)[-1])[0], - annotations=dm_anno, media=PointCloud(dm_image[0]), related_images=dm_image[1], - attributes=attributes, subset=frame_data.subset, + subset=frame_data.subset, + annotations=dm_anno, + media=dm_media, + attributes=dm_attributes, ) dm_items.append(dm_item) @@ -1732,7 +1746,7 @@ def __init__( **kwargs ): dm.Extractor.__init__( - self, media_type=dm.Image if dimension == DimensionType.DIM_2D else PointCloud + self, media_type=dm.Image if dimension == DimensionType.DIM_2D else dm.PointCloud ) CVATDataExtractorMixin.__init__(self, **kwargs) @@ -1741,12 +1755,10 @@ def __init__( self._dimension = dimension self._format_type = format_type - dm_items: List[dm.DatasetItem] = [] - if self._dimension == DimensionType.DIM_3D or include_images: - self._image_provider = IMAGE_PROVIDERS_BY_DIMENSION[self._dimension]( + self._media_provider = MEDIA_PROVIDERS_BY_DIMENSION[self._dimension]( { - task.id: ImageSource(task, is_video=task.mode == 'interpolation') + task.id: MediaSource(task) for task in project_data.tasks } ) @@ -1757,43 +1769,57 @@ def __init__( for is_video in [task.mode == 'interpolation'] } + dm_items: List[dm.DatasetItem] = [] for frame_data in project_data.group_by_frame(include_empty=True): - image_args = { - 'path': frame_data.name + ext_per_task[frame_data.task_id], - 'size': (frame_data.height, frame_data.width), - } + dm_media_args = { 'path': frame_data.name + ext_per_task[frame_data.task_id] } if self._dimension == DimensionType.DIM_3D: - dm_image = self._image_provider.get_image_for_frame( - frame_data.task_id, frame_data.id, **image_args) - elif include_images: - dm_image = self._image_provider.get_image_for_frame( - frame_data.task_id, frame_data.idx, **image_args) + dm_media: dm.PointCloud = self._media_provider.get_media_for_frame( + frame_data.task_id, frame_data.id, **dm_media_args + ) + + if not include_images: + dm_media_args["extra_images"] = [ + dm.Image(path=osp.basename(image.path)) + for image in dm_media.extra_images + ] + dm_media = dm.PointCloud(**dm_media_args) else: - dm_image = dm.Image(**image_args) + dm_media_args['size'] = (frame_data.height, frame_data.width) + if include_images: + dm_media: dm.Image = self._media_provider.get_media_for_frame( + frame_data.task_id, frame_data.idx, **dm_media_args + ) + else: + dm_media = dm.Image(**dm_media_args) + dm_anno = self._read_cvat_anno(frame_data, project_data.meta[project_data.META_FIELD]['labels']) + + dm_attributes = {'frame': frame_data.frame} + if self._dimension == DimensionType.DIM_2D: dm_item = dm.DatasetItem( id=osp.splitext(frame_data.name)[0], - annotations=dm_anno, media=dm_image, + annotations=dm_anno, media=dm_media, subset=frame_data.subset, - attributes={'frame': frame_data.frame} + attributes=dm_attributes, ) - else: - attributes = {'frame': frame_data.frame} + elif self._dimension == DimensionType.DIM_3D: if format_type == "sly_pointcloud": - attributes["name"] = self._user["name"] - attributes["createdAt"] = self._user["createdAt"] - attributes["updatedAt"] = self._user["updatedAt"] - attributes["labels"] = [] + dm_attributes["name"] = self._user["name"] + dm_attributes["createdAt"] = self._user["createdAt"] + dm_attributes["updatedAt"] = self._user["updatedAt"] + dm_attributes["labels"] = [] for (idx, (_, label)) in enumerate(project_data.meta[project_data.META_FIELD]['labels']): - attributes["labels"].append({"label_id": idx, "name": label["name"], "color": label["color"], "type": label["type"]}) - attributes["track_id"] = -1 + dm_attributes["labels"].append({"label_id": idx, "name": label["name"], "color": label["color"], "type": label["type"]}) + dm_attributes["track_id"] = -1 dm_item = dm.DatasetItem( id=osp.splitext(osp.split(frame_data.name)[-1])[0], - annotations=dm_anno, media=PointCloud(dm_image[0]), related_images=dm_image[1], - attributes=attributes, subset=frame_data.subset + annotations=dm_anno, media=dm_media, + subset=frame_data.subset, + attributes=dm_attributes, ) + dm_items.append(dm_item) self._items = dm_items @@ -2442,18 +2468,27 @@ def load_dataset_data(project_annotation, dataset: dm.Dataset, project_data): project_annotation.add_task(task_fields, dataset_files, project_data) -def detect_dataset(dataset_dir: str, format_name: str, importer: Importer) -> None: +class NoMediaInAnnotationFileError(CvatImportError): + def __str__(self) -> str: + return ( + "Can't import media data from the annotation file. " + "Please upload full dataset as a zip archive." + ) + +def detect_dataset(dataset_dir: str, format_name: str, importer: dm.Importer) -> None: not_found_error_instance = CvatDatasetNotFoundError() - def not_found_error(_, reason, human_message): + def _handle_rejection(format_name: str, reason: RejectionReason, human_message: str) -> None: not_found_error_instance.format_name = format_name not_found_error_instance.reason = reason not_found_error_instance.message = human_message - detection_env = Environment() + detection_env = dm.Environment() detection_env.importers.items.clear() detection_env.importers.register(format_name, importer) - detected = detection_env.detect_dataset(dataset_dir, depth=4, rejection_callback=not_found_error) + detected = detection_env.detect_dataset( + dataset_dir, depth=4, rejection_callback=_handle_rejection + ) if not detected and not_found_error_instance.reason != RejectionReason.detection_unsupported: raise not_found_error_instance diff --git a/cvat/apps/dataset_manager/formats/coco.py b/cvat/apps/dataset_manager/formats/coco.py index 6d63aeb0360f..1d1a8ce4d0d5 100644 --- a/cvat/apps/dataset_manager/formats/coco.py +++ b/cvat/apps/dataset_manager/formats/coco.py @@ -9,8 +9,9 @@ from datumaro.components.annotation import AnnotationType from datumaro.plugins.coco_format.importer import CocoImporter -from cvat.apps.dataset_manager.bindings import GetCVATDataExtractor, detect_dataset, \ - import_dm_annotations +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, NoMediaInAnnotationFileError, import_dm_annotations, detect_dataset +) from cvat.apps.dataset_manager.util import make_zip_archive from .registry import dm_env, exporter, importer @@ -35,6 +36,9 @@ def _import(src_file, temp_dir, instance_data, load_data_callback=None, **kwargs load_data_callback(dataset, instance_data) import_dm_annotations(dataset, instance_data) else: + if load_data_callback: + raise NoMediaInAnnotationFileError() + dataset = Dataset.import_from(src_file.name, 'coco_instances', env=dm_env) import_dm_annotations(dataset, instance_data) @@ -52,6 +56,8 @@ def _export(dst_file, temp_dir, instance_data, save_images=False): def _import(src_file, temp_dir, instance_data, load_data_callback=None, **kwargs): def remove_extra_annotations(dataset): for item in dataset: + # Boxes would have invalid (skeleton) labels, so remove them + # TODO: find a way to import boxes annotations = [ann for ann in item.annotations if ann.type != AnnotationType.bbox] item.annotations = annotations @@ -66,7 +72,9 @@ def remove_extra_annotations(dataset): load_data_callback(dataset, instance_data) import_dm_annotations(dataset, instance_data) else: - dataset = Dataset.import_from(src_file.name, - 'coco_person_keypoints', env=dm_env) + if load_data_callback: + raise NoMediaInAnnotationFileError() + + dataset = Dataset.import_from(src_file.name, 'coco_person_keypoints', env=dm_env) remove_extra_annotations(dataset) import_dm_annotations(dataset, instance_data) diff --git a/cvat/apps/dataset_manager/formats/cvat.py b/cvat/apps/dataset_manager/formats/cvat.py index 03ef389599e8..fa46b58813bf 100644 --- a/cvat/apps/dataset_manager/formats/cvat.py +++ b/cvat/apps/dataset_manager/formats/cvat.py @@ -22,10 +22,16 @@ from datumaro.util.image import Image from defusedxml import ElementTree -from cvat.apps.dataset_manager.bindings import (ProjectData, TaskData, JobData, detect_dataset, - get_defaulted_subset, - import_dm_annotations, - match_dm_item) +from cvat.apps.dataset_manager.bindings import ( + NoMediaInAnnotationFileError, + ProjectData, + TaskData, + JobData, + detect_dataset, + get_defaulted_subset, + import_dm_annotations, + match_dm_item +) from cvat.apps.dataset_manager.util import make_zip_archive from cvat.apps.engine.frame_provider import FrameQuality, FrameOutputType, make_frame_provider @@ -1456,4 +1462,7 @@ def _import(src_file, temp_dir, instance_data, load_data_callback=None, **kwargs for p in anno_paths: load_anno(p, instance_data) else: + if load_data_callback: + raise NoMediaInAnnotationFileError() + load_anno(src_file, instance_data) diff --git a/cvat/apps/dataset_manager/formats/datumaro.py b/cvat/apps/dataset_manager/formats/datumaro.py index 090397b7a471..4fc1d246dd47 100644 --- a/cvat/apps/dataset_manager/formats/datumaro.py +++ b/cvat/apps/dataset_manager/formats/datumaro.py @@ -3,43 +3,40 @@ # # SPDX-License-Identifier: MIT +import zipfile from datumaro.components.dataset import Dataset -from datumaro.components.extractor import ItemTransform -from datumaro.util.image import Image -from pyunpack import Archive - -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, detect_dataset, - import_dm_annotations) +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, import_dm_annotations, NoMediaInAnnotationFileError, detect_dataset +) from cvat.apps.dataset_manager.util import make_zip_archive from cvat.apps.engine.models import DimensionType from .registry import dm_env, exporter, importer -class DeleteImagePath(ItemTransform): - def transform_item(self, item): - image = None - if item.has_image and item.image.has_data: - image = Image(data=item.image.data, size=item.image.size) - return item.wrap(image=image, point_cloud='', related_images=[]) - @exporter(name="Datumaro", ext="ZIP", version="1.0") def _export(dst_file, temp_dir, instance_data, save_images=False): - with GetCVATDataExtractor(instance_data=instance_data, include_images=save_images) as extractor: + with GetCVATDataExtractor( + instance_data=instance_data, include_images=save_images + ) as extractor: dataset = Dataset.from_extractors(extractor, env=dm_env) - if not save_images: - dataset.transform(DeleteImagePath) dataset.export(temp_dir, 'datumaro', save_images=save_images) make_zip_archive(temp_dir, dst_file) -@importer(name="Datumaro", ext="ZIP", version="1.0") +@importer(name="Datumaro", ext="JSON, ZIP", version="1.0") def _import(src_file, temp_dir, instance_data, load_data_callback=None, **kwargs): - Archive(src_file.name).extractall(temp_dir) + if zipfile.is_zipfile(src_file): + zipfile.ZipFile(src_file).extractall(temp_dir) - detect_dataset(temp_dir, format_name='datumaro', importer=dm_env.importers.get('datumaro')) - dataset = Dataset.import_from(temp_dir, 'datumaro', env=dm_env) + detect_dataset(temp_dir, format_name='datumaro', importer=dm_env.importers.get('datumaro')) + dataset = Dataset.import_from(temp_dir, 'datumaro', env=dm_env) + else: + if load_data_callback: + raise NoMediaInAnnotationFileError() + + dataset = Dataset.import_from(src_file.name, 'datumaro', env=dm_env) if load_data_callback is not None: load_data_callback(dataset, instance_data) @@ -52,19 +49,22 @@ def _export(dst_file, temp_dir, instance_data, save_images=False): dimension=DimensionType.DIM_3D, ) as extractor: dataset = Dataset.from_extractors(extractor, env=dm_env) - - if not save_images: - dataset.transform(DeleteImagePath) dataset.export(temp_dir, 'datumaro', save_images=save_images) make_zip_archive(temp_dir, dst_file) -@importer(name="Datumaro 3D", ext="ZIP", version="1.0", dimension=DimensionType.DIM_3D) +@importer(name="Datumaro 3D", ext="JSON, ZIP", version="1.0", dimension=DimensionType.DIM_3D) def _import(src_file, temp_dir, instance_data, load_data_callback=None, **kwargs): - Archive(src_file.name).extractall(temp_dir) + if zipfile.is_zipfile(src_file): + zipfile.ZipFile(src_file).extractall(temp_dir) + + detect_dataset(temp_dir, format_name='datumaro', importer=dm_env.importers.get('datumaro')) + dataset = Dataset.import_from(temp_dir, 'datumaro', env=dm_env) + else: + if load_data_callback: + raise NoMediaInAnnotationFileError() - detect_dataset(temp_dir, format_name='datumaro', importer=dm_env.importers.get('datumaro')) - dataset = Dataset.import_from(temp_dir, 'datumaro', env=dm_env) + dataset = Dataset.import_from(src_file.name, 'datumaro', env=dm_env) if load_data_callback is not None: load_data_callback(dataset, instance_data) diff --git a/tests/python/rest_api/test_projects.py b/tests/python/rest_api/test_projects.py index abfccd5f6b03..d3d807d68088 100644 --- a/tests/python/rest_api/test_projects.py +++ b/tests/python/rest_api/test_projects.py @@ -19,7 +19,7 @@ from typing import Optional, Union import pytest -from cvat_sdk.api_client import ApiClient, Configuration, models +from cvat_sdk.api_client import ApiClient, Configuration, exceptions, models from cvat_sdk.api_client.api_client import Endpoint from cvat_sdk.api_client.exceptions import ForbiddenException from cvat_sdk.core.helpers import get_paginated_collection @@ -37,8 +37,10 @@ from shared.utils.helpers import generate_image_files from .utils import ( + DATUMARO_FORMAT_FOR_DIMENSION, CollectionSimpleFilterTestBase, create_task, + export_dataset, export_project_backup, export_project_dataset, ) @@ -991,6 +993,68 @@ def test_can_export_and_import_dataset_after_deleting_related_storage( self._test_import_project(admin_user, project_id, "CVAT 1.1", import_data) + @pytest.mark.parametrize( + "dimension, format_name", + [ + *DATUMARO_FORMAT_FOR_DIMENSION.items(), + ("2d", "CVAT 1.1"), + ("3d", "CVAT 1.1"), + ("2d", "COCO 1.0"), + ], + ) + def test_cant_import_annotations_as_project(self, admin_user, tasks, format_name, dimension): + task = next(t for t in tasks if t.get("size") if t["dimension"] == dimension) + + def _export_task(task_id: int, format_name: str) -> io.BytesIO: + with make_api_client(admin_user) as api_client: + return io.BytesIO( + export_dataset( + api_client.tasks_api, + api_version=2, + id=task_id, + format=format_name, + save_images=False, + ) + ) + + if format_name in list(DATUMARO_FORMAT_FOR_DIMENSION.values()): + with zipfile.ZipFile(_export_task(task["id"], format_name)) as zip_file: + annotations = zip_file.read("annotations/default.json") + + dataset_file = io.BytesIO(annotations) + dataset_file.name = "annotations.json" + elif format_name == "CVAT 1.1": + with zipfile.ZipFile(_export_task(task["id"], "CVAT for images 1.1")) as zip_file: + annotations = zip_file.read("annotations.xml") + + dataset_file = io.BytesIO(annotations) + dataset_file.name = "annotations.xml" + elif format_name == "COCO 1.0": + with zipfile.ZipFile(_export_task(task["id"], format_name)) as zip_file: + annotations = zip_file.read("annotations/instances_default.json") + + dataset_file = io.BytesIO(annotations) + dataset_file.name = "annotations.json" + else: + assert False + + with make_api_client(admin_user) as api_client: + project, _ = api_client.projects_api.create( + project_write_request=models.ProjectWriteRequest( + name=f"test_annotations_import_as_project {format_name}" + ) + ) + + import_data = {"dataset_file": dataset_file} + + with pytest.raises(exceptions.ApiException, match="Dataset file should be zip archive"): + self._test_import_project( + admin_user, + project.id, + format_name=format_name, + data=import_data, + ) + @pytest.mark.parametrize( "export_format, subset_path_template", [ @@ -1045,10 +1109,7 @@ def test_creates_subfolders_for_subsets_on_export( len([f for f in zip_file.namelist() if f.startswith(folder_prefix)]) > 0 ), f"No {folder_prefix} in {zip_file.namelist()}" - def test_export_project_with_honeypots( - self, - admin_user: str, - ): + def test_export_project_with_honeypots(self, admin_user: str): project_spec = { "name": "Project with honeypots", "labels": [{"name": "cat"}], diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index a61683d981e2..cf96ff50a17a 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -64,9 +64,11 @@ ) from .utils import ( + DATUMARO_FORMAT_FOR_DIMENSION, CollectionSimpleFilterTestBase, compare_annotations, create_task, + export_dataset, export_task_backup, export_task_dataset, parse_frame_step, @@ -969,6 +971,46 @@ def test_uses_subset_name( subset_path in path for path in zip_file.namelist() ), f"No {subset_path} in {zip_file.namelist()}" + @pytest.mark.parametrize( + "dimension, mode", [("2d", "annotation"), ("2d", "interpolation"), ("3d", "annotation")] + ) + def test_datumaro_export_without_annotations_includes_image_info( + self, admin_user, tasks, mode, dimension + ): + task = next( + t for t in tasks if t.get("size") if t["mode"] == mode if t["dimension"] == dimension + ) + + with make_api_client(admin_user) as api_client: + dataset_file = io.BytesIO( + export_dataset( + api_client.tasks_api, + api_version=2, + id=task["id"], + format=DATUMARO_FORMAT_FOR_DIMENSION[dimension], + save_images=False, + ) + ) + + with zipfile.ZipFile(dataset_file) as zip_file: + annotations = json.loads(zip_file.read("annotations/default.json")) + + assert annotations["items"] + for item in annotations["items"]: + assert "media" not in item + + if dimension == "2d": + assert osp.splitext(item["image"]["path"])[0] == item["id"] + assert not Path(item["image"]["path"]).is_absolute() + assert tuple(item["image"]["size"]) > (0, 0) + elif dimension == "3d": + assert osp.splitext(osp.basename(item["point_cloud"]["path"]))[0] == item["id"] + assert not Path(item["point_cloud"]["path"]).is_absolute() + for related_image in item["related_images"]: + assert not Path(related_image["path"]).is_absolute() + if "size" in related_image: + assert tuple(related_image["size"]) > (0, 0) + @pytest.mark.usefixtures("restore_db_per_function") @pytest.mark.usefixtures("restore_cvat_data_per_function") @@ -5181,6 +5223,47 @@ def test_import_annotations_after_deleting_related_cloud_storage( task.import_annotations(self.import_format, file_path) self._check_annotations(task_id) + @pytest.mark.parametrize("dimension", ["2d", "3d"]) + def test_can_import_datumaro_json(self, admin_user, tasks, dimension): + task = next( + t + for t in tasks + if t.get("size") + if t["dimension"] == dimension and t.get("validation_mode") != "gt_pool" + ) + + with make_api_client(admin_user) as api_client: + original_annotations = json.loads( + api_client.tasks_api.retrieve_annotations(task["id"])[1].data + ) + + dataset_archive = io.BytesIO( + export_dataset( + api_client.tasks_api, + api_version=2, + id=task["id"], + format=DATUMARO_FORMAT_FOR_DIMENSION[dimension], + save_images=False, + ) + ) + + with zipfile.ZipFile(dataset_archive) as zip_file: + annotations = zip_file.read("annotations/default.json") + + with TemporaryDirectory() as tempdir: + annotations_path = Path(tempdir) / "annotations.json" + annotations_path.write_bytes(annotations) + self.client.tasks.retrieve(task["id"]).import_annotations( + DATUMARO_FORMAT_FOR_DIMENSION[dimension], annotations_path + ) + + with make_api_client(admin_user) as api_client: + updated_annotations = json.loads( + api_client.tasks_api.retrieve_annotations(task["id"])[1].data + ) + + assert compare_annotations(original_annotations, updated_annotations) == {} + @pytest.mark.parametrize( "format_name", [ diff --git a/tests/python/rest_api/utils.py b/tests/python/rest_api/utils.py index 434c3705ddc3..aa747d169e9d 100644 --- a/tests/python/rest_api/utils.py +++ b/tests/python/rest_api/utils.py @@ -573,7 +573,7 @@ def create_task(username, spec, data, content_type="application/json", **kwargs) return task.id, response_.headers.get("X-Request-Id") -def compare_annotations(a, b): +def compare_annotations(a: dict, b: dict) -> dict: def _exclude_cb(obj, path): return path.endswith("['elements']") and not obj @@ -593,5 +593,11 @@ def _exclude_cb(obj, path): ) +DATUMARO_FORMAT_FOR_DIMENSION = { + "2d": "Datumaro 1.0", + "3d": "Datumaro 3D 1.0", +} + + def parse_frame_step(frame_filter: str) -> int: return int((frame_filter or "step=1").split("=")[1]) From e97ace21b7d4c0956eba47f7c20b9ce81a5bb6b3 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Mon, 25 Nov 2024 22:59:42 +0800 Subject: [PATCH 086/163] If in LDAP mode, users not being assign a default role at login (#8708) Currently if using LDAP auth IAM_TYPE, a user might have access to the application to login but no role is assigned to the user. This creates confusion to users as they are not able to perform any actions in CVAT even though they can login. In comparison, this behaviour is not encountered if using the BASIC auth IAM_TYPE, as the `IAM_DEFAULT_ROLE`, is assigned to the user when logs in for the first time. To explain a bit further the flow that we encountered, maintainers of an organization use the email service to invite users to their org, then users click on the email link, login to the application and accept the invite to the organization. Even though they might be even `maintainers` in the organization they cannot perform any action as the rego rules for organization need users to at least have the "worker" role in the application. The same goes for sending invitation to the organization if they have been made `mantainers`. Here the links to the rego files: - https://github.com/cvat-ai/cvat/blob/develop/cvat/apps/organizations/rules/organizations.rego - https://github.com/cvat-ai/cvat/blob/develop/cvat/apps/organizations/rules/invitations.rego ### How has this been tested? Yes, I have a local deployment with LDAP enabled. WIth these changes, if I log in then I get assigned the default role and I can perform actions in the application. ## Summary by CodeRabbit - **New Features** - Enhanced LDAP user management by refining group assignment logic, ensuring default roles are only assigned to non-superuser and non-staff users. - **Bug Fixes** - Improved control flow for user group assignments to prevent unnecessary role assignments for elevated privilege users. --------- Co-authored-by: Rodrigo Agundez Co-authored-by: Maria Khrustaleva --- changelog.d/20241125_193231_rragundez_ldap_default_role.md | 3 +++ cvat/apps/iam/signals.py | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 changelog.d/20241125_193231_rragundez_ldap_default_role.md diff --git a/changelog.d/20241125_193231_rragundez_ldap_default_role.md b/changelog.d/20241125_193231_rragundez_ldap_default_role.md new file mode 100644 index 000000000000..2939d1e0750e --- /dev/null +++ b/changelog.d/20241125_193231_rragundez_ldap_default_role.md @@ -0,0 +1,3 @@ +### Added + +- A default role if IAM_TYPE='LDAP' and if the user is not a member of any group in 'DJANGO_AUTH_LDAP_GROUPS' () diff --git a/cvat/apps/iam/signals.py b/cvat/apps/iam/signals.py index 28159cddc745..73f919a1a4a4 100644 --- a/cvat/apps/iam/signals.py +++ b/cvat/apps/iam/signals.py @@ -42,6 +42,9 @@ def create_user(sender, user=None, ldap_user=None, **kwargs): if role == settings.IAM_ADMIN_ROLE: user.is_staff = user.is_superuser = True break + # add default group if no other group has been assigned + if not len(user_groups): + user_groups.append(Group.objects.get(name=settings.IAM_DEFAULT_ROLE)) # It is important to save the user before adding groups. Please read # https://django-auth-ldap.readthedocs.io/en/latest/users.html#populating-users From a634f5adc6d76afddaf7d0040841c56ac9134cb5 Mon Sep 17 00:00:00 2001 From: Dmitrii Lavrukhin Date: Tue, 26 Nov 2024 15:40:08 +0300 Subject: [PATCH 087/163] enhancing analytics (#8616) ### Motivation and context 1. Minimize payload for create:tags, create:shapes, create:tracks, update:tags, update:shapes, update:tracks, delete:tags, delete:shapes, delete:tracks 2. sending update/create/delete for Memberships, Invitations, Webhooks ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **Bug Fixes** - Refined event logging by removing unnecessary fields from processed data, enhancing clarity and efficiency. --- ...5531_dmitrii.lavrukhin_minimize_payload.md | 4 + ...5732_dmitrii.lavrukhin_minimize_payload.md | 4 + cvat/apps/events/event.py | 3 + cvat/apps/events/handlers.py | 76 +++++++++++-------- cvat/apps/events/signals.py | 35 ++++++--- dev/format_python_code.sh | 1 + .../docs/administration/advanced/analytics.md | 6 ++ 7 files changed, 87 insertions(+), 42 deletions(-) create mode 100644 changelog.d/20241113_125531_dmitrii.lavrukhin_minimize_payload.md create mode 100644 changelog.d/20241113_125732_dmitrii.lavrukhin_minimize_payload.md diff --git a/changelog.d/20241113_125531_dmitrii.lavrukhin_minimize_payload.md b/changelog.d/20241113_125531_dmitrii.lavrukhin_minimize_payload.md new file mode 100644 index 000000000000..b84c500455e5 --- /dev/null +++ b/changelog.d/20241113_125531_dmitrii.lavrukhin_minimize_payload.md @@ -0,0 +1,4 @@ +### Added + +- New events (create|update|delete):(membership|webhook) and (create|delete):invitation + () diff --git a/changelog.d/20241113_125732_dmitrii.lavrukhin_minimize_payload.md b/changelog.d/20241113_125732_dmitrii.lavrukhin_minimize_payload.md new file mode 100644 index 000000000000..6001bd280d18 --- /dev/null +++ b/changelog.d/20241113_125732_dmitrii.lavrukhin_minimize_payload.md @@ -0,0 +1,4 @@ +### Changed + +- Payload for events (create|update|delete):(shapes|tags|tracks) does not include frame and attributes anymore + () diff --git a/cvat/apps/events/event.py b/cvat/apps/events/event.py index ae519b568644..a4afff968549 100644 --- a/cvat/apps/events/event.py +++ b/cvat/apps/events/event.py @@ -20,6 +20,8 @@ class EventScopes: "task": ["create", "update", "delete"], "job": ["create", "update", "delete"], "organization": ["create", "update", "delete"], + "membership": ["create", "update", "delete"], + "invitation": ["create", "delete"], "user": ["create", "update", "delete"], "cloudstorage": ["create", "update", "delete"], "issue": ["create", "update", "delete"], @@ -28,6 +30,7 @@ class EventScopes: "label": ["create", "update", "delete"], "dataset": ["export", "import"], "function": ["call"], + "webhook": ["create", "update", "delete"], } @classmethod diff --git a/cvat/apps/events/handlers.py b/cvat/apps/events/handlers.py index f2d3f7577617..8f29f91d9a1a 100644 --- a/cvat/apps/events/handlers.py +++ b/cvat/apps/events/handlers.py @@ -4,7 +4,7 @@ import datetime import traceback -from typing import Optional, Union +from typing import Any, Optional, Union import rq from crum import get_current_request, get_current_user @@ -26,6 +26,8 @@ MembershipReadSerializer, OrganizationReadSerializer) from cvat.apps.engine.rq_job_handler import RQJobMetaField +from cvat.apps.webhooks.models import Webhook +from cvat.apps.webhooks.serializers import WebhookReadSerializer from .cache import get_cache from .event import event_scope, record_server_event @@ -66,6 +68,7 @@ def task_id(instance): except Exception: return None + def job_id(instance): if isinstance(instance, Job): return instance.id @@ -78,6 +81,7 @@ def job_id(instance): except Exception: return None + def get_user(instance=None): # Try to get current user from request user = get_current_user() @@ -97,6 +101,7 @@ def get_user(instance=None): return None + def get_request(instance=None): request = get_current_request() if request is not None: @@ -111,6 +116,7 @@ def get_request(instance=None): return None + def _get_value(obj, key): if obj is not None: if isinstance(obj, dict): @@ -119,22 +125,27 @@ def _get_value(obj, key): return None + def request_id(instance=None): request = get_request(instance) return _get_value(request, "uuid") + def user_id(instance=None): current_user = get_user(instance) return _get_value(current_user, "id") + def user_name(instance=None): current_user = get_user(instance) return _get_value(current_user, "username") + def user_email(instance=None): current_user = get_user(instance) return _get_value(current_user, "email") or None + def organization_slug(instance): if isinstance(instance, Organization): return instance.slug @@ -147,6 +158,7 @@ def organization_slug(instance): except Exception: return None + def get_instance_diff(old_data, data): ignore_related_fields = ( "labels", @@ -164,7 +176,8 @@ def get_instance_diff(old_data, data): return diff -def _cleanup_fields(obj): + +def _cleanup_fields(obj: dict[str, Any]) -> dict[str, Any]: fields=( "slug", "id", @@ -183,6 +196,7 @@ def _cleanup_fields(obj): "url", "issues", "attributes", + "key", ) subfields=( "url", @@ -198,6 +212,7 @@ def _cleanup_fields(obj): data[k] = v return data + def _get_object_name(instance): if isinstance(instance, Organization) or \ isinstance(instance, Project) or \ @@ -217,34 +232,32 @@ def _get_object_name(instance): return None + +SERIALIZERS = [ + (Organization, OrganizationReadSerializer), + (Project, ProjectReadSerializer), + (Task, TaskReadSerializer), + (Job, JobReadSerializer), + (User, BasicUserSerializer), + (CloudStorage, CloudStorageReadSerializer), + (Issue, IssueReadSerializer), + (Comment, CommentReadSerializer), + (Label, LabelSerializer), + (Membership, MembershipReadSerializer), + (Invitation, InvitationReadSerializer), + (Webhook, WebhookReadSerializer), +] + + def get_serializer(instance): context = { "request": get_current_request() } serializer = None - if isinstance(instance, Organization): - serializer = OrganizationReadSerializer(instance=instance, context=context) - if isinstance(instance, Project): - serializer = ProjectReadSerializer(instance=instance, context=context) - if isinstance(instance, Task): - serializer = TaskReadSerializer(instance=instance, context=context) - if isinstance(instance, Job): - serializer = JobReadSerializer(instance=instance, context=context) - if isinstance(instance, User): - serializer = BasicUserSerializer(instance=instance, context=context) - if isinstance(instance, CloudStorage): - serializer = CloudStorageReadSerializer(instance=instance, context=context) - if isinstance(instance, Issue): - serializer = IssueReadSerializer(instance=instance, context=context) - if isinstance(instance, Comment): - serializer = CommentReadSerializer(instance=instance, context=context) - if isinstance(instance, Label): - serializer = LabelSerializer(instance=instance, context=context) - if isinstance(instance, Membership): - serializer = MembershipReadSerializer(instance=instance, context=context) - if isinstance(instance, Invitation): - serializer = InvitationReadSerializer(instance=instance, context=context) + for model, serializer_class in SERIALIZERS: + if isinstance(instance, model): + serializer = serializer_class(instance=instance, context=context) return serializer @@ -254,6 +267,7 @@ def get_serializer_without_url(instance): serializer.fields.pop("url", None) return serializer + def handle_create(scope, instance, **kwargs): oid = organization_id(instance) oslug = organization_slug(instance) @@ -288,6 +302,7 @@ def handle_create(scope, instance, **kwargs): payload=payload, ) + def handle_update(scope, instance, old_instance, **kwargs): oid = organization_id(instance) oslug = organization_slug(instance) @@ -322,12 +337,14 @@ def handle_update(scope, instance, old_instance, **kwargs): payload={"old_value": change["old_value"]}, ) + def handle_delete(scope, instance, store_in_deletion_cache=False, **kwargs): deletion_cache = get_cache() + instance_id = getattr(instance, "id", None) if store_in_deletion_cache: deletion_cache.set( instance.__class__, - instance.id, + instance_id, { "oid": organization_id(instance), "oslug": organization_slug(instance), @@ -338,7 +355,7 @@ def handle_delete(scope, instance, store_in_deletion_cache=False, **kwargs): ) return - instance_meta_info = deletion_cache.pop(instance.__class__, instance.id) + instance_meta_info = deletion_cache.pop(instance.__class__, instance_id) if instance_meta_info: oid = instance_meta_info["oid"] oslug = instance_meta_info["oslug"] @@ -360,7 +377,7 @@ def handle_delete(scope, instance, store_in_deletion_cache=False, **kwargs): scope=scope, request_id=request_id(), on_commit=True, - obj_id=getattr(instance, 'id', None), + obj_id=instance_id, obj_name=_get_object_name(instance), org_id=oid, org_slug=oslug, @@ -372,15 +389,12 @@ def handle_delete(scope, instance, store_in_deletion_cache=False, **kwargs): user_email=uemail, ) + def handle_annotations_change(instance, annotations, action, **kwargs): def filter_data(data): filtered_data = { "id": data["id"], - "frame": data["frame"], - "attributes": data["attributes"], } - if label_id := data.get("label_id"): - filtered_data["label_id"] = label_id return filtered_data diff --git a/cvat/apps/events/signals.py b/cvat/apps/events/signals.py index 25d320c35e1d..c304fc69b61c 100644 --- a/cvat/apps/events/signals.py +++ b/cvat/apps/events/signals.py @@ -2,26 +2,30 @@ # # SPDX-License-Identifier: MIT -from django.dispatch import receiver -from django.db.models.signals import pre_save, post_save, post_delete from django.core.exceptions import ObjectDoesNotExist +from django.db.models.signals import post_delete, post_save, pre_save +from django.dispatch import receiver from cvat.apps.engine.models import ( - TimestampedModel, - Project, - Task, - Job, - User, CloudStorage, - Issue, Comment, + Issue, + Job, Label, + Project, + Task, + TimestampedModel, + User, ) -from cvat.apps.organizations.models import Organization +from cvat.apps.organizations.models import Invitation, Membership, Organization +from cvat.apps.webhooks.models import Webhook -from .handlers import handle_update, handle_create, handle_delete from .event import EventScopeChoice, event_scope +from .handlers import handle_create, handle_delete, handle_update + +@receiver(pre_save, sender=Webhook, dispatch_uid="webhook:update_receiver") +@receiver(pre_save, sender=Membership, dispatch_uid="membership:update_receiver") @receiver(pre_save, sender=Organization, dispatch_uid="organization:update_receiver") @receiver(pre_save, sender=Project, dispatch_uid="project:update_receiver") @receiver(pre_save, sender=Task, dispatch_uid="task:update_receiver") @@ -34,7 +38,8 @@ def resource_update(sender, *, instance, update_fields, **kwargs): if ( isinstance(instance, TimestampedModel) - and update_fields and list(update_fields) == ["updated_date"] + and update_fields + and list(update_fields) == ["updated_date"] ): # This is an optimization for the common case where only the date is bumped # (see `TimestampedModel.touch`). Since the actual update of the field will @@ -57,6 +62,10 @@ def resource_update(sender, *, instance, update_fields, **kwargs): handle_update(scope=scope, instance=instance, old_instance=old_instance, **kwargs) + +@receiver(post_save, sender=Webhook, dispatch_uid="webhook:create_receiver") +@receiver(post_save, sender=Membership, dispatch_uid="membership:create_receiver") +@receiver(post_save, sender=Invitation, dispatch_uid="invitation:create_receiver") @receiver(post_save, sender=Organization, dispatch_uid="organization:create_receiver") @receiver(post_save, sender=Project, dispatch_uid="project:create_receiver") @receiver(post_save, sender=Task, dispatch_uid="task:create_receiver") @@ -78,6 +87,10 @@ def resource_create(sender, instance, created, **kwargs): handle_create(scope=scope, instance=instance, **kwargs) + +@receiver(post_delete, sender=Webhook, dispatch_uid="webhook:delete_receiver") +@receiver(post_delete, sender=Membership, dispatch_uid="membership:delete_receiver") +@receiver(post_delete, sender=Invitation, dispatch_uid="invitation:delete_receiver") @receiver(post_delete, sender=Organization, dispatch_uid="organization:delete_receiver") @receiver(post_delete, sender=Project, dispatch_uid="project:delete_receiver") @receiver(post_delete, sender=Task, dispatch_uid="task:delete_receiver") diff --git a/dev/format_python_code.sh b/dev/format_python_code.sh index 2e70e5b0ea4e..27fda9eff4ff 100755 --- a/dev/format_python_code.sh +++ b/dev/format_python_code.sh @@ -32,6 +32,7 @@ for paths in \ "cvat/apps/engine/model_utils.py" \ "cvat/apps/dataset_manager/tests/test_annotation.py" \ "cvat/apps/dataset_manager/tests/utils.py" \ + "cvat/apps/events/signals.py" \ ; do ${BLACK} -- ${paths} ${ISORT} -- ${paths} diff --git a/site/content/en/docs/administration/advanced/analytics.md b/site/content/en/docs/administration/advanced/analytics.md index 445c212f9687..b99ca0a824c2 100644 --- a/site/content/en/docs/administration/advanced/analytics.md +++ b/site/content/en/docs/administration/advanced/analytics.md @@ -135,6 +135,12 @@ Server events: - `call:function` +- `create:membership`, `update:membership`, `delete:membership` + +- `create:webhook`, `update:webhook`, `delete:webhook` + +- `create:invitation`, `delete:invitation` + Client events: - `load:cvat` From 7d0205b180201e676a29f847ab04e798934edf76 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Tue, 26 Nov 2024 17:20:25 +0200 Subject: [PATCH 088/163] dataset_manager: remove imports that were deprecated in Python 3.9 (#8745) This is a continuation of #8626. --- cvat/apps/dataset_manager/annotation.py | 3 +- cvat/apps/dataset_manager/bindings.py | 81 ++++++++++++------------ cvat/apps/dataset_manager/project.py | 5 +- cvat/apps/dataset_manager/tests/utils.py | 4 +- cvat/apps/dataset_manager/util.py | 3 +- 5 files changed, 50 insertions(+), 46 deletions(-) diff --git a/cvat/apps/dataset_manager/annotation.py b/cvat/apps/dataset_manager/annotation.py index 3971d5536919..0f0dd2a329b5 100644 --- a/cvat/apps/dataset_manager/annotation.py +++ b/cvat/apps/dataset_manager/annotation.py @@ -6,7 +6,8 @@ from copy import copy, deepcopy import math -from typing import Container, Optional, Sequence +from collections.abc import Container, Sequence +from typing import Optional import numpy as np from itertools import chain from scipy.optimize import linear_sum_assignment diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 9d073ca1bc73..9b01dced2a94 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -8,12 +8,13 @@ import os.path as osp import re import sys +from collections import OrderedDict, defaultdict +from collections.abc import Iterable, Iterator, Mapping, Sequence from functools import reduce from operator import add from pathlib import Path from types import SimpleNamespace -from typing import (Any, Callable, DefaultDict, Dict, Iterable, Iterator, List, Literal, Mapping, - NamedTuple, Optional, OrderedDict, Sequence, Set, Tuple, Union) +from typing import Any, Callable, Literal, NamedTuple, Optional, Union from attrs.converters import to_bool import datumaro as dm @@ -277,12 +278,12 @@ def __init__(self, self._create_callback = create_callback self._MAX_ANNO_SIZE = 30000 self._frame_info = {} - self._frame_mapping: Dict[str, int] = {} + self._frame_mapping: dict[str, int] = {} self._frame_step = db_task.data.get_frame_step() self._db_data: models.Data = db_task.data self._use_server_track_ids = use_server_track_ids self._required_frames = included_frames - self._initialized_included_frames: Optional[Set[int]] = None + self._initialized_included_frames: Optional[set[int]] = None self._db_subset = db_task.subset super().__init__(db_task) @@ -960,9 +961,9 @@ class LabeledShape: type: str = attrib() frame: int = attrib() label: str = attrib() - points: List[float] = attrib() + points: list[float] = attrib() occluded: bool = attrib() - attributes: List[InstanceLabelData.Attribute] = attrib() + attributes: list[InstanceLabelData.Attribute] = attrib() source: str = attrib(default='manual') group: int = attrib(default=0) rotation: int = attrib(default=0) @@ -970,40 +971,40 @@ class LabeledShape: task_id: int = attrib(default=None) subset: str = attrib(default=None) outside: bool = attrib(default=False) - elements: List['ProjectData.LabeledShape'] = attrib(default=[]) + elements: list['ProjectData.LabeledShape'] = attrib(default=[]) @attrs class TrackedShape: type: str = attrib() frame: int = attrib() - points: List[float] = attrib() + points: list[float] = attrib() occluded: bool = attrib() outside: bool = attrib() keyframe: bool = attrib() - attributes: List[InstanceLabelData.Attribute] = attrib() + attributes: list[InstanceLabelData.Attribute] = attrib() rotation: int = attrib(default=0) source: str = attrib(default='manual') group: int = attrib(default=0) z_order: int = attrib(default=0) label: str = attrib(default=None) track_id: int = attrib(default=0) - elements: List['ProjectData.TrackedShape'] = attrib(default=[]) + elements: list['ProjectData.TrackedShape'] = attrib(default=[]) @attrs class Track: label: str = attrib() - shapes: List['ProjectData.TrackedShape'] = attrib() + shapes: list['ProjectData.TrackedShape'] = attrib() source: str = attrib(default='manual') group: int = attrib(default=0) task_id: int = attrib(default=None) subset: str = attrib(default=None) - elements: List['ProjectData.Track'] = attrib(default=[]) + elements: list['ProjectData.Track'] = attrib(default=[]) @attrs class Tag: frame: int = attrib() label: str = attrib() - attributes: List[InstanceLabelData.Attribute] = attrib() + attributes: list[InstanceLabelData.Attribute] = attrib() source: str = attrib(default='manual') group: int = attrib(default=0) task_id: int = attrib(default=None) @@ -1017,8 +1018,8 @@ class Frame: name: str = attrib() width: int = attrib() height: int = attrib() - labeled_shapes: List[Union['ProjectData.LabeledShape', 'ProjectData.TrackedShape']] = attrib() - tags: List['ProjectData.Tag'] = attrib() + labeled_shapes: list[Union['ProjectData.LabeledShape', 'ProjectData.TrackedShape']] = attrib() + tags: list['ProjectData.Tag'] = attrib() task_id: int = attrib(default=None) subset: str = attrib(default=None) @@ -1037,12 +1038,12 @@ def __init__(self, self._host = host self._soft_attribute_import = False self._project_annotation = project_annotation - self._tasks_data: Dict[int, TaskData] = {} - self._frame_info: Dict[Tuple[int, int], Literal["path", "width", "height", "subset"]] = dict() + self._tasks_data: dict[int, TaskData] = {} + self._frame_info: dict[tuple[int, int], Literal["path", "width", "height", "subset"]] = dict() # (subset, path): (task id, frame number) - self._frame_mapping: Dict[Tuple[str, str], Tuple[int, int]] = dict() - self._frame_steps: Dict[int, int] = {} - self.new_tasks: Set[int] = set() + self._frame_mapping: dict[tuple[str, str], tuple[int, int]] = dict() + self._frame_steps: dict[int, int] = {} + self.new_tasks: set[int] = set() self._use_server_track_ids = use_server_track_ids InstanceLabelData.__init__(self, db_project) @@ -1080,12 +1081,12 @@ def _init_tasks(self): subsets = set() for task in self._db_tasks.values(): subsets.add(task.subset) - self._subsets: List[str] = list(subsets) + self._subsets: list[str] = list(subsets) - self._frame_steps: Dict[int, int] = {task.id: task.data.get_frame_step() for task in self._db_tasks.values()} + self._frame_steps: dict[int, int] = {task.id: task.data.get_frame_step() for task in self._db_tasks.values()} def _init_task_frame_offsets(self): - self._task_frame_offsets: Dict[int, int] = dict() + self._task_frame_offsets: dict[int, int] = dict() s = 0 subset = None @@ -1100,7 +1101,7 @@ def _init_task_frame_offsets(self): def _init_frame_info(self): self._frame_info = dict() self._deleted_frames = { (task.id, frame): True for task in self._db_tasks.values() for frame in task.data.deleted_frames } - original_names = DefaultDict[Tuple[str, str], int](int) + original_names = defaultdict[tuple[str, str], int](int) for task in self._db_tasks.values(): defaulted_subset = get_defaulted_subset(task.subset, self._subsets) if hasattr(task.data, 'video'): @@ -1254,7 +1255,7 @@ def _export_track(self, track: dict, task_id: int, task_size: int, idx: int): ) def group_by_frame(self, include_empty: bool = False): - frames: Dict[Tuple[str, int], ProjectData.Frame] = {} + frames: dict[tuple[str, int], ProjectData.Frame] = {} def get_frame(task_id: int, idx: int) -> ProjectData.Frame: frame_info = self._frame_info[(task_id, idx)] abs_frame = self.abs_frame_id(task_id, idx) @@ -1365,7 +1366,7 @@ def db_project(self): return self._db_project @property - def subsets(self) -> List[str]: + def subsets(self) -> list[str]: return self._subsets @property @@ -1447,7 +1448,7 @@ def split_dataset(self, dataset: dm.Dataset): subset_dataset: dm.Dataset = dataset.subsets()[task_data.db_instance.subset].as_dataset() yield subset_dataset, task_data - def add_labels(self, labels: List[dict]): + def add_labels(self, labels: list[dict]): attributes = [] _labels = [] for label in labels: @@ -1468,14 +1469,14 @@ def is_video(self) -> bool: return self.db_task.mode == 'interpolation' class MediaProvider: - def __init__(self, sources: Dict[int, MediaSource]) -> None: + def __init__(self, sources: dict[int, MediaSource]) -> None: self._sources = sources def unload(self) -> None: pass class MediaProvider2D(MediaProvider): - def __init__(self, sources: Dict[int, MediaSource]) -> None: + def __init__(self, sources: dict[int, MediaSource]) -> None: super().__init__(sources) self._current_source_id = None self._frame_provider = None @@ -1526,7 +1527,7 @@ def _unload_source(self) -> None: self._current_source_id = None class MediaProvider3D(MediaProvider): - def __init__(self, sources: Dict[int, MediaSource]) -> None: + def __init__(self, sources: dict[int, MediaSource]) -> None: super().__init__(sources) self._images_per_source = { source_id: { @@ -1554,7 +1555,7 @@ def get_media_for_frame(self, source_id: int, frame_id: int, **image_kwargs) -> return dm.PointCloud(point_cloud_path, extra_images=related_images) -MEDIA_PROVIDERS_BY_DIMENSION: Dict[DimensionType, MediaProvider] = { +MEDIA_PROVIDERS_BY_DIMENSION: dict[DimensionType, MediaProvider] = { DimensionType.DIM_3D: MediaProvider3D, DimensionType.DIM_2D: MediaProvider2D, } @@ -1579,7 +1580,7 @@ def categories(self) -> dict: @staticmethod def _load_categories(labels: list): - categories: Dict[dm.AnnotationType, + categories: dict[dm.AnnotationType, dm.Categories] = {} label_categories = dm.LabelCategories(attributes=['occluded']) @@ -1666,7 +1667,7 @@ def __init__( {0: MediaSource(db_task)} ) - dm_items: List[dm.DatasetItem] = [] + dm_items: list[dm.DatasetItem] = [] for frame_data in instance_data.group_by_frame(include_empty=True): dm_media_args = { 'path': frame_data.name + ext } if dimension == DimensionType.DIM_3D: @@ -1763,13 +1764,13 @@ def __init__( } ) - ext_per_task: Dict[int, str] = { + ext_per_task: dict[int, str] = { task.id: TaskFrameProvider.VIDEO_FRAME_EXT if is_video else '' for task in project_data.tasks for is_video in [task.mode == 'interpolation'] } - dm_items: List[dm.DatasetItem] = [] + dm_items: list[dm.DatasetItem] = [] for frame_data in project_data.group_by_frame(include_empty=True): dm_media_args = { 'path': frame_data.name + ext_per_task[frame_data.task_id] } if self._dimension == DimensionType.DIM_3D: @@ -1881,7 +1882,7 @@ def _clean_display_message(self) -> str: message = "Dataset must contain a file:" + message return re.sub(r' +', " ", message) -def mangle_image_name(name: str, subset: str, names: DefaultDict[Tuple[str, str], int]) -> str: +def mangle_image_name(name: str, subset: str, names: defaultdict[tuple[str, str], int]) -> str: name, ext = name.rsplit(osp.extsep, maxsplit=1) if not names[(subset, name)]: @@ -1902,7 +1903,7 @@ def mangle_image_name(name: str, subset: str, names: DefaultDict[Tuple[str, str] i += 1 raise Exception('Cannot mangle image name') -def get_defaulted_subset(subset: str, subsets: List[str]) -> str: +def get_defaulted_subset(subset: str, subsets: list[str]) -> str: if subset: return subset else: @@ -2064,7 +2065,7 @@ def _convert_shape(self, return results - def _convert_shapes(self, shapes: List[CommonData.LabeledShape]) -> Iterable[dm.Annotation]: + def _convert_shapes(self, shapes: list[CommonData.LabeledShape]) -> Iterable[dm.Annotation]: dm_anno = [] self.num_of_tracks = reduce( @@ -2078,7 +2079,7 @@ def _convert_shapes(self, shapes: List[CommonData.LabeledShape]) -> Iterable[dm. return dm_anno - def convert(self) -> List[dm.Annotation]: + def convert(self) -> list[dm.Annotation]: dm_anno = [] dm_anno.extend(self._convert_tags(self.cvat_frame_anno.tags)) dm_anno.extend(self._convert_shapes(self.cvat_frame_anno.labeled_shapes)) @@ -2091,7 +2092,7 @@ def convert_cvat_anno_to_dm( map_label, format_name=None, dimension=DimensionType.DIM_2D -) -> List[dm.Annotation]: +) -> list[dm.Annotation]: converter = CvatToDmAnnotationConverter( cvat_frame_anno=cvat_frame_anno, label_attrs=label_attrs, diff --git a/cvat/apps/dataset_manager/project.py b/cvat/apps/dataset_manager/project.py index 759483b10a06..93ac651cf477 100644 --- a/cvat/apps/dataset_manager/project.py +++ b/cvat/apps/dataset_manager/project.py @@ -4,9 +4,10 @@ # SPDX-License-Identifier: MIT import os +from collections.abc import Mapping from tempfile import TemporaryDirectory import rq -from typing import Any, Callable, List, Mapping, Tuple +from typing import Any, Callable from datumaro.components.errors import DatasetError, DatasetImportError, DatasetNotFoundError from django.db import transaction @@ -109,7 +110,7 @@ def split_name(file): project_data.new_tasks.add(db_task.id) project_data.init() - def add_labels(self, labels: List[models.Label], attributes: List[Tuple[str, models.AttributeSpec]] = None): + def add_labels(self, labels: list[models.Label], attributes: list[tuple[str, models.AttributeSpec]] = None): for label in labels: label.project = self.db_project # We need label_id here, so we can't use bulk_create here diff --git a/cvat/apps/dataset_manager/tests/utils.py b/cvat/apps/dataset_manager/tests/utils.py index 9a134b887bf7..6e3b51a878d9 100644 --- a/cvat/apps/dataset_manager/tests/utils.py +++ b/cvat/apps/dataset_manager/tests/utils.py @@ -6,7 +6,7 @@ import tempfile import unittest from types import TracebackType -from typing import Optional, Type +from typing import Optional from datumaro.util.os_util import rmfile, rmtree @@ -23,7 +23,7 @@ def __enter__(self) -> str: def __exit__( self, - exc_type: Optional[Type[BaseException]], + exc_type: Optional[type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: diff --git a/cvat/apps/dataset_manager/util.py b/cvat/apps/dataset_manager/util.py index 0193748446f3..2f1029049bbf 100644 --- a/cvat/apps/dataset_manager/util.py +++ b/cvat/apps/dataset_manager/util.py @@ -8,11 +8,12 @@ import os.path as osp import re import zipfile +from collections.abc import Generator, Sequence from contextlib import contextmanager from copy import deepcopy from datetime import timedelta from threading import Lock -from typing import Any, Generator, Optional, Sequence +from typing import Any, Optional import attrs import django_rq From edef764b3eee13fd47b2cf31d2baa2684ad35ad6 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Tue, 26 Nov 2024 18:21:50 +0200 Subject: [PATCH 089/163] Rename `FunctionCallRequestSerializer.convMaskToPoly` to fit the naming convention (#8743) Keep the old name as a compatibility alias for the time being. --- .../20241126_140417_roman_rename_conv_mask_to_poly.md | 11 +++++++++++ .../controls-side-bar/tools-control.tsx | 4 ++-- .../components/model-runner-modal/detector-runner.tsx | 4 ++-- cvat/apps/lambda_manager/serializers.py | 3 ++- cvat/apps/lambda_manager/views.py | 2 +- cvat/schema.yml | 6 +++++- 6 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 changelog.d/20241126_140417_roman_rename_conv_mask_to_poly.md diff --git a/changelog.d/20241126_140417_roman_rename_conv_mask_to_poly.md b/changelog.d/20241126_140417_roman_rename_conv_mask_to_poly.md new file mode 100644 index 000000000000..1788c1fe6c41 --- /dev/null +++ b/changelog.d/20241126_140417_roman_rename_conv_mask_to_poly.md @@ -0,0 +1,11 @@ +### Added + +- The `POST /api/lambda/requests` endpoint now has a `conv_mask_to_poly` + parameter with the same semantics as the old `convMaskToPoly` parameter + () + +### Deprecated + +- The `convMaskToPoly` parameter of the `POST /api/lambda/requests` endpoint + is deprecated; use `conv_mask_to_poly` instead + () diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/tools-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/tools-control.tsx index a31307277e68..dc73360d1f1d 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/tools-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/tools-control.tsx @@ -1254,8 +1254,8 @@ export class ToolsControlComponent extends React.PureComponent { try { this.setState({ mode: 'detection', fetching: true }); - // The function call endpoint doesn't support the cleanup and convMaskToPoly parameters. - const { cleanup, convMaskToPoly, ...restOfBody } = body; + // The function call endpoint doesn't support the cleanup and conv_mask_to_poly parameters. + const { cleanup, conv_mask_to_poly: convMaskToPoly, ...restOfBody } = body; const result = await core.lambda.call(jobInstance.taskId, model, { ...restOfBody, frame, job: jobInstance.id, diff --git a/cvat-ui/src/components/model-runner-modal/detector-runner.tsx b/cvat-ui/src/components/model-runner-modal/detector-runner.tsx index d6c92826b662..ab3393b2c290 100644 --- a/cvat-ui/src/components/model-runner-modal/detector-runner.tsx +++ b/cvat-ui/src/components/model-runner-modal/detector-runner.tsx @@ -40,7 +40,7 @@ type ServerMapping = Record Date: Wed, 27 Nov 2024 15:18:39 +0300 Subject: [PATCH 090/163] Added test for not showing ground truth annotations in standard view (#8742) ### Motivation and context Test for #8675 ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - ~~[ ] I have created a changelog fragment ~~ - ~~[ ] I have updated the documentation accordingly~~ - [x] I have added tests to cover my changes - ~~[ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword))~~ - ~~[ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning))~~ ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **Bug Fixes** - Improved test reliability by changing the test setup to run before each test case. - **New Features** - Added a test case to ensure ground truth annotations are not visible in the standard annotation view after creation. --- .../cypress/e2e/features/ground_truth_jobs.js | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/tests/cypress/e2e/features/ground_truth_jobs.js b/tests/cypress/e2e/features/ground_truth_jobs.js index 01cac1da5f6f..482a940c3d68 100644 --- a/tests/cypress/e2e/features/ground_truth_jobs.js +++ b/tests/cypress/e2e/features/ground_truth_jobs.js @@ -361,7 +361,7 @@ context('Ground truth jobs', () => { describe('Regression tests', () => { const serverFiles = ['bigArchive.zip']; - before(() => { + beforeEach(() => { createAndOpenTask(serverFiles); }); @@ -400,5 +400,51 @@ context('Ground truth jobs', () => { jobID = Number(url.split('/').slice(-1)[0].split('?')[0]); }).should('match', /\/tasks\/\d+\/jobs\/\d+/); }); + + it('Check GT annotations can not be shown in standard annotation view', () => { + cy.headlessCreateJob({ + task_id: taskID, + frame_count: 4, + type: 'ground_truth', + frame_selection_method: 'random_uniform', + seed: 1, + }).then((jobResponse) => { + groundTruthJobID = jobResponse.jobID; + return cy.headlessCreateObjects(groundTruthFrames.map((frame, index) => { + const gtRect = groundTruthRectangles[index]; + return { + labelName, + objectType: 'shape', + shapeType: 'rectangle', + occluded: false, + frame, + points: [gtRect.firstX, gtRect.firstY, gtRect.secondX, gtRect.secondY], + }; + }), groundTruthJobID); + }).then(() => { + cy.visit(`/tasks/${taskID}/jobs/${jobID}`); + cy.get('.cvat-canvas-container').should('exist'); + + cy.changeWorkspace('Review'); + cy.get('.cvat-objects-sidebar-show-ground-truth').click(); + cy.get('.cvat-objects-sidebar-show-ground-truth').should( + 'have.class', 'cvat-objects-sidebar-show-ground-truth-active', + ); + groundTruthFrames.forEach((frame, index) => { + cy.goCheckFrameNumber(frame); + checkRectangleAndObjectMenu(groundTruthRectangles[index]); + }); + + cy.interactMenu('Open the task'); + cy.get('.cvat-task-job-list').within(() => { + cy.contains('a', `Job #${jobID}`).click(); + }); + groundTruthFrames.forEach((frame) => { + cy.goCheckFrameNumber(frame); + cy.get('.cvat_canvas_shape').should('not.exist'); + cy.get('.cvat-objects-sidebar-state-item').should('not.exist'); + }); + }); + }); }); }); From 9581b07fe938701387d5c9837f7b6d1843b94938 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Thu, 28 Nov 2024 13:31:29 +0200 Subject: [PATCH 091/163] Do not hide annotations actions dialog if action is failed (#8751) --- .../annotations-actions/annotations-actions-modal.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx b/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx index b5587f39ff99..f33dd9bf231a 100644 --- a/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx +++ b/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx @@ -583,14 +583,15 @@ function AnnotationsActionsModalContent(props: Props): JSX.Element { if (!cancellationRef.current) { canvasInstance.setup(frameData, []); storage.dispatch(fetchAnnotationsAsync()); + if (isMounted()) { + if (targetObjectState !== null) { + onClose(); + } + } } }).finally(() => { if (isMounted()) { - if (targetObjectState !== null) { - onClose(); - } else { - dispatch(reducerActions.resetAfterRun()); - } + dispatch(reducerActions.resetAfterRun()); } }).catch((error: unknown) => { if (error instanceof Error) { From 88a330157dd69907657f7e4dd00417eacf70c0c0 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Thu, 28 Nov 2024 15:46:23 +0200 Subject: [PATCH 092/163] Dockerfile.ui: improve cacheability (#8753) --- Dockerfile.ui | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Dockerfile.ui b/Dockerfile.ui index 170ee1a76633..da9c36d38960 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,13 @@ 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 WA_PAGE_VIEW_HIT +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}" \ From 9091be814da4f95f85c9a533d24e83d2bfc87a77 Mon Sep 17 00:00:00 2001 From: Andrey Zhavoronkov Date: Thu, 28 Nov 2024 17:32:50 +0300 Subject: [PATCH 093/163] Prepare chunks in a worker process (#8618) Added: - Prepare chunks in rq workers instead of application server process - Increase TTL for cached preview images ### Motivation and context ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [x] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced new debugging configurations for various RQ workers, enhancing debugging capabilities. - Added a new service for processing chunks in Docker and Kubernetes configurations. - Enhanced error handling in data retrieval methods to manage timeout scenarios effectively. - **Bug Fixes** - Improved robustness of job-related functionalities with enhanced test coverage and validation checks. - **Documentation** - Updated API documentation settings and configurations for better clarity and usability. - **Tests** - Expanded test suite with new test cases and parameterization for comprehensive validation of task and cloud storage functionalities. --- .vscode/launch.json | 30 +- ...0241107_154537_andrey_worker_for_chunks.md | 4 + cvat/apps/engine/cache.py | 387 ++++++++++++++---- cvat/apps/engine/default_settings.py | 10 + cvat/apps/engine/frame_provider.py | 145 ++++--- cvat/apps/engine/rq_job_handler.py | 3 +- cvat/apps/engine/serializers.py | 107 +++-- cvat/apps/engine/utils.py | 19 +- cvat/apps/engine/views.py | 46 ++- cvat/settings/base.py | 19 +- docker-compose.yml | 16 + .../cvat_backend/worker_chunks/deployment.yml | 96 +++++ helm-chart/test.values.yaml | 6 + helm-chart/values.yaml | 10 + supervisord/worker.chunks.conf | 29 ++ tests/docker-compose.file_share.yml | 3 + tests/docker-compose.minio.yml | 1 + tests/python/cli/test_cli.py | 2 + tests/python/rest_api/test_jobs.py | 1 + tests/python/rest_api/test_tasks.py | 8 +- tests/python/sdk/test_auto_annotation.py | 1 + tests/python/sdk/test_datasets.py | 1 + tests/python/sdk/test_pytorch.py | 1 + 23 files changed, 761 insertions(+), 184 deletions(-) create mode 100644 changelog.d/20241107_154537_andrey_worker_for_chunks.md create mode 100644 helm-chart/templates/cvat_backend/worker_chunks/deployment.yml create mode 100644 supervisord/worker.chunks.conf diff --git a/.vscode/launch.json b/.vscode/launch.json index af93ae24c007..78f24c96ca83 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", @@ -553,7 +576,8 @@ "server: RQ - scheduler", "server: RQ - quality reports", "server: RQ - analytics reports", - "server: RQ - cleaning" + "server: RQ - cleaning", + "server: RQ - chunks", ] } ] diff --git a/changelog.d/20241107_154537_andrey_worker_for_chunks.md b/changelog.d/20241107_154537_andrey_worker_for_chunks.md new file mode 100644 index 000000000000..64ee2d5c4f34 --- /dev/null +++ b/changelog.d/20241107_154537_andrey_worker_for_chunks.md @@ -0,0 +1,4 @@ +### Changed + +- Chunks are now prepared in a separate worker process + () diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 295e405a41da..197c10f14d71 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -10,6 +10,7 @@ import os.path import pickle # nosec import tempfile +import time import zipfile import zlib from contextlib import ExitStack, closing @@ -29,11 +30,18 @@ overload, ) +import attrs import av import cv2 +import django_rq import PIL.Image import PIL.ImageOps +import rq +from django.conf import settings from django.core.cache import caches +from django.db import models as django_models +from django.utils import timezone as django_tz +from redis.exceptions import LockError from rest_framework.exceptions import NotFound, ValidationError from cvat.apps.engine import models @@ -54,74 +62,254 @@ ZipChunkWriter, ZipCompressedChunkWriter, ) -from cvat.apps.engine.utils import load_image, md5_hash +from cvat.apps.engine.rq_job_handler import RQJobMetaField +from cvat.apps.engine.utils import ( + CvatChunkTimestampMismatchError, + get_rq_lock_for_job, + load_image, + md5_hash, +) from utils.dataset_manifest import ImageManifestManager slogger = ServerLogManager(__name__) DataWithMime = Tuple[io.BytesIO, str] -_CacheItem = Tuple[io.BytesIO, str, int] +_CacheItem = Tuple[io.BytesIO, str, int, Union[datetime, None]] + + +def enqueue_create_chunk_job( + queue: rq.Queue, + rq_job_id: str, + create_callback: Callback, + *, + blocking_timeout: int = 50, + rq_job_result_ttl: int = 60, + rq_job_failure_ttl: int = 3600 * 24 * 14, # 2 weeks +) -> rq.job.Job: + try: + with get_rq_lock_for_job(queue, rq_job_id, blocking_timeout=blocking_timeout): + rq_job = queue.fetch_job(rq_job_id) + + if not rq_job: + rq_job = queue.enqueue( + create_callback, + job_id=rq_job_id, + result_ttl=rq_job_result_ttl, + failure_ttl=rq_job_failure_ttl, + ) + except LockError: + raise TimeoutError(f"Cannot acquire lock for {rq_job_id}") + + return rq_job + + +def wait_for_rq_job(rq_job: rq.job.Job): + retries = settings.CVAT_CHUNK_CREATE_TIMEOUT // settings.CVAT_CHUNK_CREATE_CHECK_INTERVAL or 1 + while retries > 0: + job_status = rq_job.get_status() + if job_status in ("finished",): + return + elif job_status in ("failed",): + job_meta = rq_job.get_meta() + exc_type = job_meta.get(RQJobMetaField.EXCEPTION_TYPE, Exception) + exc_args = job_meta.get(RQJobMetaField.EXCEPTION_ARGS, ("Cannot create chunk",)) + raise exc_type(*exc_args) + + time.sleep(settings.CVAT_CHUNK_CREATE_CHECK_INTERVAL) + retries -= 1 + + raise TimeoutError(f"Chunk processing takes too long {rq_job.id}") + + +def _is_run_inside_rq() -> bool: + return rq.get_current_job() is not None + + +def _convert_args_for_callback(func_args: list[Any]) -> list[Any]: + result = [] + for func_arg in func_args: + if _is_run_inside_rq(): + result.append(func_arg) + else: + if isinstance( + func_arg, + django_models.Model, + ): + result.append(func_arg.id) + elif isinstance(func_arg, list): + result.append(_convert_args_for_callback(func_arg)) + else: + result.append(func_arg) + + return result + + +@attrs.frozen +class Callback: + _callable: Callable[..., DataWithMime] = attrs.field( + validator=attrs.validators.is_callable(), + ) + _args: list[Any] = attrs.field( + factory=list, + validator=attrs.validators.instance_of(list), + converter=_convert_args_for_callback, + ) + _kwargs: dict[str, Union[bool, int, float, str, None]] = attrs.field( + factory=dict, + validator=attrs.validators.deep_mapping( + key_validator=attrs.validators.instance_of(str), + value_validator=attrs.validators.instance_of((bool, int, float, str, type(None))), + mapping_validator=attrs.validators.instance_of(dict), + ), + ) + + def __call__(self) -> DataWithMime: + return self._callable(*self._args, **self._kwargs) class MediaCache: - def __init__(self) -> None: - self._cache = caches["media"] + _QUEUE_NAME = settings.CVAT_QUEUES.CHUNKS.value + _QUEUE_JOB_PREFIX_TASK = "chunks:prepare-item-" + _CACHE_NAME = "media" + _PREVIEW_TTL = settings.CVAT_PREVIEW_CACHE_TTL - def _get_checksum(self, value: bytes) -> int: + @staticmethod + def _cache(): + return caches[MediaCache._CACHE_NAME] + + @staticmethod + def _get_checksum(value: bytes) -> int: return zlib.crc32(value) def _get_or_set_cache_item( - self, key: str, create_callback: Callable[[], DataWithMime] + self, + key: str, + create_callback: Callback, + *, + cache_item_ttl: Optional[int] = None, ) -> _CacheItem: - def create_item() -> _CacheItem: - slogger.glob.info(f"Starting to prepare chunk: key {key}") - item_data = create_callback() - slogger.glob.info(f"Ending to prepare chunk: key {key}") + item = self._get_cache_item(key) + if item: + return item - item_data_bytes = item_data[0].getvalue() - item = (item_data[0], item_data[1], self._get_checksum(item_data_bytes)) - if item_data_bytes: - self._cache.set(key, item) + return self._create_cache_item( + key, + create_callback, + cache_item_ttl=cache_item_ttl, + ) - return item + def _get_queue(self) -> rq.Queue: + return django_rq.get_queue(self._QUEUE_NAME) - item = self._get_cache_item(key) - if not item: - item = create_item() + def _make_queue_job_id(self, key: str) -> str: + return f"{self._QUEUE_JOB_PREFIX_TASK}{key}" + + @staticmethod + def _drop_return_value(func: Callable[..., DataWithMime], *args: Any, **kwargs: Any): + func(*args, **kwargs) + + @classmethod + def _create_and_set_cache_item( + cls, + key: str, + create_callback: Callback, + cache_item_ttl: Optional[int] = None, + ) -> DataWithMime: + timestamp = django_tz.now() + item_data = create_callback() + item_data_bytes = item_data[0].getvalue() + item = (item_data[0], item_data[1], cls._get_checksum(item_data_bytes), timestamp) + if item_data_bytes: + cache = cls._cache() + cache.set(key, item, timeout=cache_item_ttl or cache.default_timeout) + + return item + + def _create_cache_item( + self, + key: str, + create_callback: Callback, + *, + cache_item_ttl: Optional[int] = None, + ) -> _CacheItem: + + queue = self._get_queue() + rq_id = self._make_queue_job_id(key) + + slogger.glob.info(f"Starting to prepare chunk: key {key}") + if _is_run_inside_rq(): + with get_rq_lock_for_job(queue, rq_id, timeout=None, blocking_timeout=None): + item = self._create_and_set_cache_item( + key, + create_callback, + cache_item_ttl=cache_item_ttl, + ) else: - # compare checksum - item_data = item[0].getbuffer() if isinstance(item[0], io.BytesIO) else item[0] - item_checksum = item[2] if len(item) == 3 else None - if item_checksum != self._get_checksum(item_data): - slogger.glob.info(f"Recreating cache item {key} due to checksum mismatch") - item = create_item() + rq_job = enqueue_create_chunk_job( + queue=queue, + rq_job_id=rq_id, + create_callback=Callback( + callable=self._drop_return_value, + args=[ + self._create_and_set_cache_item, + key, + create_callback, + ], + kwargs={ + "cache_item_ttl": cache_item_ttl, + }, + ), + ) + wait_for_rq_job(rq_job) + item = self._get_cache_item(key) + + slogger.glob.info(f"Ending to prepare chunk: key {key}") return item def _delete_cache_item(self, key: str): try: - self._cache.delete(key) + self._cache().delete(key) slogger.glob.info(f"Removed chunk from the cache: key {key}") except pickle.UnpicklingError: slogger.glob.error(f"Failed to remove item from the cache: key {key}", exc_info=True) def _get_cache_item(self, key: str) -> Optional[_CacheItem]: - slogger.glob.info(f"Starting to get chunk from cache: key {key}") try: - item = self._cache.get(key) + item = self._cache().get(key) except pickle.UnpicklingError: slogger.glob.error(f"Unable to get item from cache: key {key}", exc_info=True) item = None - slogger.glob.info(f"Ending to get chunk from cache: key {key}, is_cached {bool(item)}") + + if not item: + return None + + item_data = item[0].getbuffer() if isinstance(item[0], io.BytesIO) else item[0] + item_checksum = item[2] if len(item) == 4 else None + if item_checksum != self._get_checksum(item_data): + slogger.glob.info(f"Cache item {key} checksum mismatch") + return None return item - def _has_key(self, key: str) -> bool: - return self._cache.has_key(key) + def _validate_cache_item_timestamp( + self, item: _CacheItem, expected_timestamp: datetime + ) -> _CacheItem: + if item[3] < expected_timestamp: + raise CvatChunkTimestampMismatchError( + f"Cache timestamp mismatch. Item_ts: {item[3]}, expected_ts: {expected_timestamp}" + ) + + return item + @classmethod + def _has_key(cls, key: str) -> bool: + return cls._cache().has_key(key) + + @staticmethod def _make_cache_key_prefix( - self, obj: Union[models.Task, models.Segment, models.Job, models.CloudStorage] + obj: Union[models.Task, models.Segment, models.Job, models.CloudStorage] ) -> str: if isinstance(obj, models.Task): return f"task_{obj.id}" @@ -134,14 +322,15 @@ def _make_cache_key_prefix( else: assert False, f"Unexpected object type {type(obj)}" + @classmethod def _make_chunk_key( - self, + cls, db_obj: Union[models.Task, models.Segment, models.Job], chunk_number: int, *, quality: FrameQuality, ) -> str: - return f"{self._make_cache_key_prefix(db_obj)}_chunk_{chunk_number}_{quality}" + return f"{cls._make_cache_key_prefix(db_obj)}_chunk_{chunk_number}_{quality}" def _make_preview_key(self, db_obj: Union[models.Segment, models.CloudStorage]) -> str: return f"{self._make_cache_key_prefix(db_obj)}_preview" @@ -173,35 +362,47 @@ def _to_data_with_mime(self, cache_item: Optional[_CacheItem]) -> Optional[DataW def get_or_set_segment_chunk( self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality ) -> DataWithMime: + + item = self._get_or_set_cache_item( + self._make_chunk_key(db_segment, chunk_number, quality=quality), + Callback( + callable=self.prepare_segment_chunk, + args=[db_segment, chunk_number], + kwargs={"quality": quality}, + ), + ) + db_segment.refresh_from_db(fields=["chunks_updated_date"]) + return self._to_data_with_mime( - self._get_or_set_cache_item( - key=self._make_chunk_key(db_segment, chunk_number, quality=quality), - create_callback=lambda: self.prepare_segment_chunk( - db_segment, chunk_number, quality=quality - ), - ) + self._validate_cache_item_timestamp(item, db_segment.chunks_updated_date) ) def get_task_chunk( self, db_task: models.Task, chunk_number: int, *, quality: FrameQuality ) -> Optional[DataWithMime]: return self._to_data_with_mime( - self._get_cache_item(key=self._make_chunk_key(db_task, chunk_number, quality=quality)) + self._get_cache_item( + key=self._make_chunk_key(db_task, chunk_number, quality=quality), + ) ) def get_or_set_task_chunk( self, db_task: models.Task, chunk_number: int, + set_callback: Callback, *, quality: FrameQuality, - set_callback: Callable[[], DataWithMime], ) -> DataWithMime: + + item = self._get_or_set_cache_item( + self._make_chunk_key(db_task, chunk_number, quality=quality), + set_callback, + ) + db_task.refresh_from_db(fields=["segment_set"]) + return self._to_data_with_mime( - self._get_or_set_cache_item( - key=self._make_chunk_key(db_task, chunk_number, quality=quality), - create_callback=set_callback, - ) + self._validate_cache_item_timestamp(item, db_task.get_chunks_updated_date()) ) def get_segment_task_chunk( @@ -209,7 +410,7 @@ def get_segment_task_chunk( ) -> Optional[DataWithMime]: return self._to_data_with_mime( self._get_cache_item( - key=self._make_segment_task_chunk_key(db_segment, chunk_number, quality=quality) + key=self._make_segment_task_chunk_key(db_segment, chunk_number, quality=quality), ) ) @@ -219,13 +420,17 @@ def get_or_set_segment_task_chunk( chunk_number: int, *, quality: FrameQuality, - set_callback: Callable[[], DataWithMime], + set_callback: Callback, ) -> DataWithMime: + + item = self._get_or_set_cache_item( + self._make_segment_task_chunk_key(db_segment, chunk_number, quality=quality), + set_callback, + ) + db_segment.refresh_from_db(fields=["chunks_updated_date"]) + return self._to_data_with_mime( - self._get_or_set_cache_item( - key=self._make_segment_task_chunk_key(db_segment, chunk_number, quality=quality), - create_callback=set_callback, - ) + self._validate_cache_item_timestamp(item, db_segment.chunks_updated_date), ) def get_or_set_selective_job_chunk( @@ -233,9 +438,13 @@ def get_or_set_selective_job_chunk( ) -> DataWithMime: return self._to_data_with_mime( self._get_or_set_cache_item( - key=self._make_chunk_key(db_job, chunk_number, quality=quality), - create_callback=lambda: self.prepare_masked_range_segment_chunk( - db_job.segment, chunk_number, quality=quality + self._make_chunk_key(db_job, chunk_number, quality=quality), + Callback( + callable=self.prepare_masked_range_segment_chunk, + args=[db_job.segment, chunk_number], + kwargs={ + "quality": quality, + }, ), ) ) @@ -244,7 +453,11 @@ def get_or_set_segment_preview(self, db_segment: models.Segment) -> DataWithMime return self._to_data_with_mime( self._get_or_set_cache_item( self._make_preview_key(db_segment), - create_callback=lambda: self._prepare_segment_preview(db_segment), + Callback( + callable=self._prepare_segment_preview, + args=[db_segment], + ), + cache_item_ttl=self._PREVIEW_TTL, ) ) @@ -262,7 +475,11 @@ def get_or_set_cloud_preview(self, db_storage: models.CloudStorage) -> DataWithM return self._to_data_with_mime( self._get_or_set_cache_item( self._make_preview_key(db_storage), - create_callback=lambda: self._prepare_cloud_preview(db_storage), + Callback( + callable=self._prepare_cloud_preview, + args=[db_storage], + ), + cache_item_ttl=self._PREVIEW_TTL, ) ) @@ -271,13 +488,16 @@ def get_or_set_frame_context_images_chunk( ) -> DataWithMime: return self._to_data_with_mime( self._get_or_set_cache_item( - key=self._make_context_image_preview_key(db_data, frame_number), - create_callback=lambda: self.prepare_context_images_chunk(db_data, frame_number), + self._make_context_image_preview_key(db_data, frame_number), + Callback( + callable=self.prepare_context_images_chunk, + args=[db_data, frame_number], + ), ) ) + @staticmethod def _read_raw_images( - self, db_task: models.Task, frame_ids: Sequence[int], *, @@ -361,9 +581,13 @@ def _read_raw_images( yield from media + @staticmethod def _read_raw_frames( - self, db_task: models.Task, frame_ids: Sequence[int] + db_task: Union[models.Task, int], frame_ids: Sequence[int] ) -> Generator[Tuple[Union[av.VideoFrame, PIL.Image.Image], str, str], None, None]: + if isinstance(db_task, int): + db_task = models.Task.objects.get(pk=db_task) + for prev_frame, cur_frame in pairwise(frame_ids): assert ( prev_frame <= cur_frame @@ -400,11 +624,14 @@ def _read_raw_frames( for frame_tuple in reader.iterate_frames(frame_filter=frame_ids): yield frame_tuple else: - yield from self._read_raw_images(db_task, frame_ids, manifest_path=manifest_path) + yield from MediaCache._read_raw_images(db_task, frame_ids, manifest_path=manifest_path) def prepare_segment_chunk( - self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality + self, db_segment: Union[models.Segment, int], chunk_number: int, *, quality: FrameQuality ) -> DataWithMime: + if isinstance(db_segment, int): + db_segment = models.Segment.objects.get(pk=db_segment) + if db_segment.type == models.SegmentType.RANGE: return self.prepare_range_segment_chunk(db_segment, chunk_number, quality=quality) elif db_segment.type == models.SegmentType.SPECIFIC_FRAMES: @@ -427,10 +654,11 @@ def prepare_range_segment_chunk( return self.prepare_custom_range_segment_chunk(db_task, chunk_frame_ids, quality=quality) + @classmethod def prepare_custom_range_segment_chunk( - self, db_task: models.Task, frame_ids: Sequence[int], *, quality: FrameQuality + cls, db_task: models.Task, frame_ids: Sequence[int], *, quality: FrameQuality ) -> DataWithMime: - with closing(self._read_raw_frames(db_task, frame_ids=frame_ids)) as frame_iter: + with closing(cls._read_raw_frames(db_task, frame_ids=frame_ids)) as frame_iter: return prepare_chunk(frame_iter, quality=quality, db_task=db_task) def prepare_masked_range_segment_chunk( @@ -448,15 +676,19 @@ def prepare_masked_range_segment_chunk( db_task, chunk_frame_ids, chunk_number, quality=quality ) + @classmethod def prepare_custom_masked_range_segment_chunk( - self, - db_task: models.Task, + cls, + db_task: Union[models.Task, int], frame_ids: Collection[int], chunk_number: int, *, quality: FrameQuality, insert_placeholders: bool = False, ) -> DataWithMime: + if isinstance(db_task, int): + db_task = models.Task.objects.get(pk=db_task) + db_data = db_task.data frame_step = db_data.get_frame_step() @@ -493,8 +725,8 @@ def prepare_custom_masked_range_segment_chunk( if not list(chunk_frames): continue - chunk_available = self._has_key( - self._make_chunk_key(db_segment, i, quality=quality) + chunk_available = cls._has_key( + cls._make_chunk_key(db_segment, i, quality=quality) ) available_chunks.append(chunk_available) @@ -521,7 +753,7 @@ def get_frames(): frame_range = frame_ids if not use_cached_data: - frames_gen = self._read_raw_frames(db_task, frame_ids) + frames_gen = cls._read_raw_frames(db_task, frame_ids) frames_iter = iter(es.enter_context(closing(frames_gen))) for abs_frame_idx in frame_range: @@ -569,7 +801,10 @@ def get_frames(): buff.seek(0) return buff, get_chunk_mime_type_for_writer(writer) - def _prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: + def _prepare_segment_preview(self, db_segment: Union[models.Segment, int]) -> DataWithMime: + if isinstance(db_segment, int): + db_segment = models.Segment.objects.get(pk=db_segment) + if db_segment.task.dimension == models.DimensionType.DIM_3D: # TODO preview = PIL.Image.open( @@ -591,7 +826,10 @@ def _prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: return prepare_preview_image(preview) - def _prepare_cloud_preview(self, db_storage: models.CloudStorage) -> DataWithMime: + def _prepare_cloud_preview(self, db_storage: Union[models.CloudStorage, int]) -> DataWithMime: + if isinstance(db_storage, int): + db_storage = models.CloudStorage.objects.get(pk=db_storage) + storage = db_storage_to_storage_instance(db_storage) if not db_storage.manifests.count(): raise ValidationError("Cannot get the cloud storage preview. There is no manifest file") @@ -631,7 +869,12 @@ def _prepare_cloud_preview(self, db_storage: models.CloudStorage) -> DataWithMim image = PIL.Image.open(buff) return prepare_preview_image(image) - def prepare_context_images_chunk(self, db_data: models.Data, frame_number: int) -> DataWithMime: + def prepare_context_images_chunk( + self, db_data: Union[models.Data, int], frame_number: int + ) -> DataWithMime: + if isinstance(db_data, int): + db_data = models.Data.objects.get(pk=db_data) + zip_buffer = io.BytesIO() related_images = db_data.related_files.filter(images__frame=frame_number).all() diff --git a/cvat/apps/engine/default_settings.py b/cvat/apps/engine/default_settings.py index 826fe1c9bef2..15e1b3fd8c32 100644 --- a/cvat/apps/engine/default_settings.py +++ b/cvat/apps/engine/default_settings.py @@ -14,3 +14,13 @@ When enabled, this option can increase data access speed and reduce server load, but significantly increase disk space occupied by tasks. """ + +CVAT_CHUNK_CREATE_TIMEOUT = 50 +""" +Sets the chunk preparation timeout in seconds after which the backend will respond with 429 code. +""" + +CVAT_CHUNK_CREATE_CHECK_INTERVAL = 0.2 +""" +Sets the frequency of checking the readiness of the chunk +""" diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 1787d84aac40..2da1741b5bc7 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -10,6 +10,7 @@ import math from abc import ABCMeta, abstractmethod from bisect import bisect +from collections import OrderedDict from dataclasses import dataclass from enum import Enum, auto from io import BytesIO @@ -36,7 +37,7 @@ from rest_framework.exceptions import ValidationError from cvat.apps.engine import models -from cvat.apps.engine.cache import DataWithMime, MediaCache, prepare_chunk +from cvat.apps.engine.cache import Callback, DataWithMime, MediaCache, prepare_chunk from cvat.apps.engine.media_extractors import ( FrameQuality, IMediaReader, @@ -310,38 +311,60 @@ def get_chunk( # The requested frames match one of the job chunks, we can use it directly return segment_frame_provider.get_chunk(matching_chunk_index, quality=quality) - def _set_callback() -> DataWithMime: - # Create and return a joined / cleaned chunk - task_chunk_frames = {} - for db_segment in matching_segments: - segment_frame_provider = SegmentFrameProvider(db_segment) - segment_frame_set = db_segment.frame_set - - for task_chunk_frame_id in sorted(task_chunk_frame_set): - if ( - task_chunk_frame_id not in segment_frame_set - or task_chunk_frame_id in task_chunk_frames - ): - continue - - frame, frame_name, _ = segment_frame_provider._get_raw_frame( - self.get_rel_frame_number(task_chunk_frame_id), quality=quality - ) - task_chunk_frames[task_chunk_frame_id] = (frame, frame_name, None) - - return prepare_chunk( - task_chunk_frames.values(), - quality=quality, - db_task=self._db_task, - dump_unchanged=True, - ) - buffer, mime_type = cache.get_or_set_task_chunk( - self._db_task, chunk_number, quality=quality, set_callback=_set_callback + self._db_task, + chunk_number, + quality=quality, + set_callback=Callback( + callable=self._get_chunk_create_callback, + args=[ + self._db_task, + matching_segments, + {f: self.get_rel_frame_number(f) for f in task_chunk_frame_set}, + quality, + ], + ), ) return return_type(data=buffer, mime=mime_type) + @staticmethod + def _get_chunk_create_callback( + db_task: Union[models.Task, int], + matching_segments: list[models.Segment], + task_chunk_frames_with_rel_numbers: dict[int, int], + quality: FrameQuality, + ) -> DataWithMime: + # Create and return a joined / cleaned chunk + task_chunk_frames = OrderedDict() + for db_segment in matching_segments: + if isinstance(db_segment, int): + db_segment = models.Segment.objects.get(pk=db_segment) + segment_frame_provider = SegmentFrameProvider(db_segment) + segment_frame_set = db_segment.frame_set + + for task_chunk_frame_id in sorted(task_chunk_frames_with_rel_numbers.keys()): + if ( + task_chunk_frame_id not in segment_frame_set + or task_chunk_frame_id in task_chunk_frames + ): + continue + + frame, frame_name, _ = segment_frame_provider._get_raw_frame( + task_chunk_frames_with_rel_numbers[task_chunk_frame_id], quality=quality + ) + task_chunk_frames[task_chunk_frame_id] = (frame, frame_name, None) + + if isinstance(db_task, int): + db_task = models.Task.objects.get(pk=db_task) + + return prepare_chunk( + task_chunk_frames.values(), + quality=quality, + db_task=db_task, + dump_unchanged=True, + ) + def get_frame( self, frame_number: int, @@ -664,35 +687,55 @@ def get_chunk( if matching_chunk is not None: return self.get_chunk(matching_chunk, quality=quality) - def _set_callback() -> DataWithMime: - # Create and return a joined / cleaned chunk - segment_chunk_frame_ids = sorted( - task_chunk_frame_set.intersection(self._db_segment.frame_set) - ) - - if self._db_segment.type == models.SegmentType.RANGE: - return cache.prepare_custom_range_segment_chunk( - db_task=self._db_segment.task, - frame_ids=segment_chunk_frame_ids, - quality=quality, - ) - elif self._db_segment.type == models.SegmentType.SPECIFIC_FRAMES: - return cache.prepare_custom_masked_range_segment_chunk( - db_task=self._db_segment.task, - frame_ids=segment_chunk_frame_ids, - chunk_number=chunk_number, - quality=quality, - insert_placeholders=True, - ) - else: - assert False + segment_chunk_frame_ids = sorted( + task_chunk_frame_set.intersection(self._db_segment.frame_set) + ) buffer, mime_type = cache.get_or_set_segment_task_chunk( - self._db_segment, chunk_number, quality=quality, set_callback=_set_callback + self._db_segment, + chunk_number, + quality=quality, + set_callback=Callback( + callable=self._get_chunk_create_callback, + args=[ + self._db_segment, + segment_chunk_frame_ids, + chunk_number, + quality, + ], + ), ) return return_type(data=buffer, mime=mime_type) + @staticmethod + def _get_chunk_create_callback( + db_segment: Union[models.Segment, int], + segment_chunk_frame_ids: list[int], + chunk_number: int, + quality: FrameQuality, + ) -> DataWithMime: + # Create and return a joined / cleaned chunk + if isinstance(db_segment, int): + db_segment = models.Segment.objects.get(pk=db_segment) + + if db_segment.type == models.SegmentType.RANGE: + return MediaCache.prepare_custom_range_segment_chunk( + db_task=db_segment.task, + frame_ids=segment_chunk_frame_ids, + quality=quality, + ) + elif db_segment.type == models.SegmentType.SPECIFIC_FRAMES: + return MediaCache.prepare_custom_masked_range_segment_chunk( + db_task=db_segment.task, + frame_ids=segment_chunk_frame_ids, + chunk_number=chunk_number, + quality=quality, + insert_placeholders=True, + ) + else: + assert False + @overload def make_frame_provider(data_source: models.Job) -> JobFrameProvider: ... diff --git a/cvat/apps/engine/rq_job_handler.py b/cvat/apps/engine/rq_job_handler.py index 25900fba20a9..bef7d94eaa69 100644 --- a/cvat/apps/engine/rq_job_handler.py +++ b/cvat/apps/engine/rq_job_handler.py @@ -28,7 +28,8 @@ class RQJobMetaField: # export specific fields RESULT_URL = 'result_url' FUNCTION_ID = 'function_id' - + EXCEPTION_TYPE = 'exc_type' + EXCEPTION_ARGS = 'exc_args' def is_rq_job_owner(rq_job: RQJob, user_id: int) -> bool: return rq_job.meta.get(RQJobMetaField.USER, {}).get('id') == user_id diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 5b3845f8260e..f8678248d2b8 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -11,6 +11,7 @@ import re import shutil import string +import django_rq import rq.defaults as rq_defaults from tempfile import NamedTemporaryFile @@ -22,14 +23,15 @@ from decimal import Decimal from rest_framework import serializers, exceptions +from django.conf import settings from django.contrib.auth.models import User, Group from django.db import transaction from django.utils import timezone from numpy import random from cvat.apps.dataset_manager.formats.utils import get_label_color -from cvat.apps.engine.frame_provider import TaskFrameProvider -from cvat.apps.engine.utils import format_list, parse_exception_message +from cvat.apps.engine.frame_provider import TaskFrameProvider, FrameQuality +from cvat.apps.engine.utils import format_list, parse_exception_message, CvatChunkTimestampMismatchError from cvat.apps.engine import field_validation, models from cvat.apps.engine.cloud_provider import get_cloud_storage_instance, Credentials, Status from cvat.apps.engine.log import ServerLogManager @@ -980,8 +982,8 @@ def validate(self, attrs): @transaction.atomic def update(self, instance: models.Job, validated_data: dict[str, Any]) -> models.Job: - from cvat.apps.engine.cache import MediaCache - from cvat.apps.engine.frame_provider import FrameQuality, JobFrameProvider, prepare_chunk + from cvat.apps.engine.cache import MediaCache, Callback, enqueue_create_chunk_job, wait_for_rq_job + from cvat.apps.engine.frame_provider import JobFrameProvider from cvat.apps.dataset_manager.task import JobAnnotation, AnnotationManager db_job = instance @@ -1129,7 +1131,6 @@ def _to_rel_frame(abs_frame: int) -> int: job_annotation.delete(job_annotation_manager.data) # Update chunks - task_frame_provider = TaskFrameProvider(db_task) job_frame_provider = JobFrameProvider(db_job) updated_segment_chunk_ids = set( job_frame_provider.get_chunk_number(updated_segment_frame_id) @@ -1138,7 +1139,7 @@ def _to_rel_frame(abs_frame: int) -> int: segment_frames = sorted(segment_frame_set) segment_frame_map = dict(zip(segment_honeypots, requested_frames)) - media_cache = MediaCache() + queue = django_rq.get_queue(settings.CVAT_QUEUES.CHUNKS.value) for chunk_id in sorted(updated_segment_chunk_ids): chunk_frames = segment_frames[ chunk_id * db_data.chunk_size : @@ -1146,36 +1147,26 @@ def _to_rel_frame(abs_frame: int) -> int: ] for quality in FrameQuality.__members__.values(): - def _write_updated_static_chunk(): - def _iterate_chunk_frames(): - for chunk_frame in chunk_frames: - db_frame = all_task_frames[chunk_frame] - chunk_real_frame = segment_frame_map.get(chunk_frame, chunk_frame) - yield ( - task_frame_provider.get_frame( - chunk_real_frame, quality=quality - ).data, - os.path.basename(db_frame.path), - chunk_frame, - ) - - with closing(_iterate_chunk_frames()) as frame_iter: - chunk, _ = prepare_chunk( - frame_iter, quality=quality, db_task=db_task, dump_unchanged=True, - ) - - get_chunk_path = { - FrameQuality.COMPRESSED: db_data.get_compressed_segment_chunk_path, - FrameQuality.ORIGINAL: db_data.get_original_segment_chunk_path, - }[quality] - - with open(get_chunk_path(chunk_id, db_segment.id), 'wb') as f: - f.write(chunk.getvalue()) - if db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM: - _write_updated_static_chunk() + rq_id = f"segment_{db_segment.id}_write_chunk_{chunk_id}_{quality}" + rq_job = enqueue_create_chunk_job( + queue=queue, + rq_job_id=rq_id, + create_callback=Callback( + callable=self._write_updated_static_chunk, + args=[ + db_segment.id, + chunk_id, + chunk_frames, + quality, + {chunk_frame: all_task_frames[chunk_frame].path for chunk_frame in chunk_frames}, + segment_frame_map, + ], + ), + ) + wait_for_rq_job(rq_job) - media_cache.remove_segment_chunk(db_segment, chunk_id, quality=quality) + MediaCache().remove_segment_chunk(db_segment, chunk_id, quality=quality) db_segment.chunks_updated_date = timezone.now() db_segment.save(update_fields=['chunks_updated_date']) @@ -1199,6 +1190,54 @@ def _iterate_chunk_frames(): return instance + @staticmethod + def _write_updated_static_chunk( + db_segment_id: int, + chunk_id: int, + chunk_frames: list[int], + quality: FrameQuality, + frame_path_map: dict[int, str], + segment_frame_map: dict[int,int], + ): + from cvat.apps.engine.frame_provider import prepare_chunk + + db_segment = models.Segment.objects.select_related("task").get(pk=db_segment_id) + initial_chunks_updated_date = db_segment.chunks_updated_date + db_task = db_segment.task + task_frame_provider = TaskFrameProvider(db_task) + db_data = db_task.data + + def _iterate_chunk_frames(): + for chunk_frame in chunk_frames: + db_frame_path = frame_path_map[chunk_frame] + chunk_real_frame = segment_frame_map.get(chunk_frame, chunk_frame) + yield ( + task_frame_provider.get_frame( + chunk_real_frame, quality=quality + ).data, + os.path.basename(db_frame_path), + chunk_frame, + ) + + with closing(_iterate_chunk_frames()) as frame_iter: + chunk, _ = prepare_chunk( + frame_iter, quality=quality, db_task=db_task, dump_unchanged=True, + ) + + get_chunk_path = { + FrameQuality.COMPRESSED: db_data.get_compressed_segment_chunk_path, + FrameQuality.ORIGINAL: db_data.get_original_segment_chunk_path, + }[quality] + + db_segment.refresh_from_db(fields=["chunks_updated_date"]) + if db_segment.chunks_updated_date > initial_chunks_updated_date: + raise CvatChunkTimestampMismatchError( + "Attempting to write an out of date static chunk, " + f"segment.chunks_updated_date: {db_segment.chunks_updated_date}, expected_ts: {initial_chunks_updated_date}" + ) + with open(get_chunk_path(chunk_id, db_segment_id), 'wb') as f: + f.write(chunk.getvalue()) + class JobValidationLayoutReadSerializer(serializers.Serializer): honeypot_count = serializers.IntegerField(min_value=0, required=False) honeypot_frames = serializers.ListField( diff --git a/cvat/apps/engine/utils.py b/cvat/apps/engine/utils.py index b45cb1baf020..72cb52eb5168 100644 --- a/cvat/apps/engine/utils.py +++ b/cvat/apps/engine/utils.py @@ -97,6 +97,9 @@ def execute_python_code(source_code, global_vars=None, local_vars=None): line_number = traceback.extract_tb(tb)[-1][1] raise InterpreterError("{} at line {}: {}".format(error_class, line_number, details)) +class CvatChunkTimestampMismatchError(Exception): + pass + def av_scan_paths(*paths): if 'yes' == os.environ.get('CLAM_AV'): command = ['clamscan', '--no-summary', '-i', '-o'] @@ -198,14 +201,22 @@ def define_dependent_job( return Dependency(jobs=[sorted(user_jobs, key=lambda job: job.created_at)[-1]], allow_failure=True) if user_jobs else None -def get_rq_lock_by_user(queue: DjangoRQ, user_id: int) -> Union[Lock, nullcontext]: +def get_rq_lock_by_user(queue: DjangoRQ, user_id: int, *, timeout: Optional[int] = 30, blocking_timeout: Optional[int] = None) -> Union[Lock, nullcontext]: if settings.ONE_RUNNING_JOB_IN_QUEUE_PER_USER: - return queue.connection.lock(f'{queue.name}-lock-{user_id}', timeout=30) + return queue.connection.lock( + name=f'{queue.name}-lock-{user_id}', + timeout=timeout, + blocking_timeout=blocking_timeout, + ) return nullcontext() -def get_rq_lock_for_job(queue: DjangoRQ, rq_id: str) -> Lock: +def get_rq_lock_for_job(queue: DjangoRQ, rq_id: str, *, timeout: Optional[int] = 60, blocking_timeout: Optional[int] = None) -> Lock: # lock timeout corresponds to the nginx request timeout (proxy_read_timeout) - return queue.connection.lock(f'lock-for-job-{rq_id}'.lower(), timeout=60) + return queue.connection.lock( + name=f'lock-for-job-{rq_id}'.lower(), + timeout=timeout, + blocking_timeout=blocking_timeout, + ) def get_rq_job_meta( request: HttpRequest, diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index ac046b1d0b26..a73cf9449a60 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -106,7 +106,7 @@ from .log import ServerLogManager from cvat.apps.iam.filters import ORGANIZATION_OPEN_API_PARAMETERS from cvat.apps.iam.permissions import PolicyEnforcer, IsAuthenticatedOrReadPublicResource -from cvat.apps.engine.cache import MediaCache +from cvat.apps.engine.cache import MediaCache, CvatChunkTimestampMismatchError, LockError from cvat.apps.engine.permissions import (CloudStoragePermission, CommentPermission, IssuePermission, JobPermission, LabelPermission, ProjectPermission, TaskPermission, UserPermission) @@ -118,6 +118,7 @@ _DATA_CHECKSUM_HEADER_NAME = 'X-Checksum' _DATA_UPDATED_DATE_HEADER_NAME = 'X-Updated-Date' +_RETRY_AFTER_TIMEOUT = 10 @extend_schema(tags=['server']) class ServerViewSet(viewsets.ViewSet): @@ -723,6 +724,11 @@ def __call__(self): msg = str(ex) if not isinstance(ex, ValidationError) else \ '\n'.join([str(d) for d in ex.detail]) return Response(data=msg, status=ex.status_code) + except (TimeoutError, CvatChunkTimestampMismatchError, LockError): + return Response( + status=status.HTTP_429_TOO_MANY_REQUESTS, + headers={'Retry-After': _RETRY_AFTER_TIMEOUT}, + ) @abstractmethod def _get_chunk_response_headers(self, chunk_data: DataWithMeta) -> dict[str, str]: ... @@ -806,20 +812,26 @@ def __call__(self): # Reproduce the task chunk indexing frame_provider = self._get_frame_provider() - if self.index is not None: - data = frame_provider.get_chunk( - self.index, quality=self.quality, is_task_chunk=False + try: + if self.index is not None: + data = frame_provider.get_chunk( + self.index, quality=self.quality, is_task_chunk=False + ) + else: + data = frame_provider.get_chunk( + self.number, quality=self.quality, is_task_chunk=True + ) + + return HttpResponse( + data.data.getvalue(), + content_type=data.mime, + headers=self._get_chunk_response_headers(data), ) - else: - data = frame_provider.get_chunk( - self.number, quality=self.quality, is_task_chunk=True + except (TimeoutError, CvatChunkTimestampMismatchError, LockError): + return Response( + status=status.HTTP_429_TOO_MANY_REQUESTS, + headers={'Retry-After': _RETRY_AFTER_TIMEOUT}, ) - - return HttpResponse( - data.data.getvalue(), - content_type=data.mime, - headers=self._get_chunk_response_headers(data), - ) else: return super().__call__() @@ -2968,6 +2980,11 @@ def preview(self, request, pk): '\n'.join([str(d) for d in ex.detail]) slogger.cloud_storage[pk].info(msg) return Response(data=msg, status=ex.status_code) + except (TimeoutError, CvatChunkTimestampMismatchError, LockError): + return Response( + status=status.HTTP_429_TOO_MANY_REQUESTS, + headers={'Retry-After': _RETRY_AFTER_TIMEOUT}, + ) except Exception as ex: slogger.glob.error(str(ex)) return Response("An internal error has occurred", @@ -3254,6 +3271,9 @@ def perform_destroy(self, instance): def rq_exception_handler(rq_job, exc_type, exc_value, tb): rq_job.meta[RQJobMetaField.FORMATTED_EXCEPTION] = "".join( traceback.format_exception_only(exc_type, exc_value)) + if rq_job.origin == settings.CVAT_QUEUES.CHUNKS.value: + rq_job.meta[RQJobMetaField.EXCEPTION_TYPE] = exc_type + rq_job.meta[RQJobMetaField.EXCEPTION_ARGS] = exc_value.args rq_job.save_meta() return True diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 2ba8d5bc6bcb..0f6147dc4bf0 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -276,6 +276,7 @@ class CVAT_QUEUES(Enum): QUALITY_REPORTS = 'quality_reports' ANALYTICS_REPORTS = 'analytics_reports' CLEANING = 'cleaning' + CHUNKS = 'chunks' redis_inmem_host = os.getenv('CVAT_REDIS_INMEM_HOST', 'localhost') redis_inmem_port = os.getenv('CVAT_REDIS_INMEM_PORT', 6379) @@ -321,6 +322,10 @@ class CVAT_QUEUES(Enum): **shared_queue_settings, 'DEFAULT_TIMEOUT': '1h', }, + CVAT_QUEUES.CHUNKS.value: { + **shared_queue_settings, + 'DEFAULT_TIMEOUT': '5m', + }, } NUCLIO = { @@ -539,14 +544,20 @@ class CVAT_QUEUES(Enum): redis_ondisk_port = os.getenv('CVAT_REDIS_ONDISK_PORT', 6666) redis_ondisk_password = os.getenv('CVAT_REDIS_ONDISK_PASSWORD', '') +# Sets the timeout for the expiration of data chunk in redis_ondisk +CVAT_CHUNK_CACHE_TTL = 3600 * 24 # 1 day + +# Sets the timeout for the expiration of preview image in redis_ondisk +CVAT_PREVIEW_CACHE_TTL = 3600 * 24 * 7 # 7 days + CACHES = { - 'default': { + 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', }, 'media': { - 'BACKEND' : 'django.core.cache.backends.redis.RedisCache', - "LOCATION": f"redis://:{urllib.parse.quote(redis_ondisk_password)}@{redis_ondisk_host}:{redis_ondisk_port}", - 'TIMEOUT' : 3600 * 24, # 1 day + 'BACKEND' : 'django.core.cache.backends.redis.RedisCache', + "LOCATION": f'redis://:{urllib.parse.quote(redis_ondisk_password)}@{redis_ondisk_host}:{redis_ondisk_port}', + 'TIMEOUT' : CVAT_CHUNK_CACHE_TTL, } } diff --git a/docker-compose.yml b/docker-compose.yml index 0d3f802c82f5..a921b70cbf9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -224,6 +224,22 @@ services: networks: - cvat + cvat_worker_chunks: + container_name: cvat_worker_chunks + image: cvat/server:${CVAT_VERSION:-dev} + restart: always + depends_on: *backend-deps + environment: + <<: *backend-env + NUMPROCS: 2 + command: run worker.chunks + volumes: + - cvat_data:/home/django/data + - cvat_keys:/home/django/keys + - cvat_logs:/home/django/logs + networks: + - cvat + cvat_ui: container_name: cvat_ui image: cvat/ui:${CVAT_VERSION:-dev} diff --git a/helm-chart/templates/cvat_backend/worker_chunks/deployment.yml b/helm-chart/templates/cvat_backend/worker_chunks/deployment.yml new file mode 100644 index 000000000000..74e80b1b185d --- /dev/null +++ b/helm-chart/templates/cvat_backend/worker_chunks/deployment.yml @@ -0,0 +1,96 @@ +{{- $localValues := .Values.cvat.backend.worker.chunks -}} + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-backend-worker-chunks + namespace: {{ .Release.Namespace }} + labels: + app: cvat-app + tier: backend + component: worker-chunks + {{- include "cvat.labels" . | nindent 4 }} + {{- with merge $localValues.labels .Values.cvat.backend.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with merge $localValues.annotations .Values.cvat.backend.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + replicas: {{ $localValues.replicas }} + strategy: + type: Recreate + selector: + matchLabels: + {{- include "cvat.labels" . | nindent 6 }} + {{- with merge $localValues.labels .Values.cvat.backend.labels }} + {{- toYaml . | nindent 6 }} + {{- end }} + app: cvat-app + tier: backend + component: worker-chunks + template: + metadata: + labels: + app: cvat-app + tier: backend + component: worker-chunks + {{- include "cvat.labels" . | nindent 8 }} + {{- with merge $localValues.labels .Values.cvat.backend.labels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with merge $localValues.annotations .Values.cvat.backend.annotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "cvat.backend.serviceAccountName" . }} + containers: + - name: cvat-backend + image: {{ .Values.cvat.backend.image }}:{{ .Values.cvat.backend.tag }} + imagePullPolicy: {{ .Values.cvat.backend.imagePullPolicy }} + {{- with merge $localValues.resources .Values.cvat.backend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + args: ["run", "worker.chunks"] + env: + {{ include "cvat.sharedBackendEnv" . | indent 10 }} + {{- with concat .Values.cvat.backend.additionalEnv $localValues.additionalEnv }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- $probeArgs := list "chunks" -}} + {{- $probeConfig := dict "args" $probeArgs "livenessProbe" $.Values.cvat.backend.worker.livenessProbe -}} + {{ include "cvat.backend.worker.livenessProbe" $probeConfig | indent 10 }} + volumeMounts: + - mountPath: /home/django/data + name: cvat-backend-data + subPath: data + - mountPath: /home/django/logs + name: cvat-backend-data + subPath: logs + {{- with concat .Values.cvat.backend.additionalVolumeMounts $localValues.additionalVolumeMounts }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with merge $localValues.affinity .Values.cvat.backend.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with concat .Values.cvat.backend.tolerations $localValues.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + {{- if .Values.cvat.backend.defaultStorage.enabled }} + - name: cvat-backend-data + persistentVolumeClaim: + claimName: "{{ .Release.Name }}-backend-data" + {{- end }} + {{- with concat .Values.cvat.backend.additionalVolumes $localValues.additionalVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm-chart/test.values.yaml b/helm-chart/test.values.yaml index 2e83933c2103..350cc384c178 100644 --- a/helm-chart/test.values.yaml +++ b/helm-chart/test.values.yaml @@ -21,6 +21,12 @@ cvat: subPath: share export: replicas: 1 + chunks: + replicas: 1 + additionalVolumeMounts: + - mountPath: /home/django/share + name: cvat-backend-data + subPath: share utils: additionalEnv: - name: DJANGO_SETTINGS_MODULE diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index a55087469281..ae0180efd972 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -117,6 +117,16 @@ cvat: additionalEnv: [] additionalVolumes: [] additionalVolumeMounts: [] + chunks: + replicas: 2 + labels: {} + annotations: {} + resources: {} + affinity: {} + tolerations: [] + additionalEnv: [] + additionalVolumes: [] + additionalVolumeMounts: [] utils: replicas: 1 labels: {} diff --git a/supervisord/worker.chunks.conf b/supervisord/worker.chunks.conf new file mode 100644 index 000000000000..9eccd41e8cba --- /dev/null +++ b/supervisord/worker.chunks.conf @@ -0,0 +1,29 @@ +[unix_http_server] +file = /tmp/supervisord/supervisor.sock + +[supervisorctl] +serverurl = unix:///tmp/supervisord/supervisor.sock + + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisord] +nodaemon=true +logfile=%(ENV_HOME)s/logs/supervisord.log ; supervisord log file +logfile_maxbytes=50MB ; maximum size of logfile before rotation +logfile_backups=10 ; number of backed up logfiles +loglevel=debug ; info, debug, warn, trace +pidfile=/tmp/supervisord/supervisord.pid ; pidfile location + +[program:rqworker-chunks] +command=%(ENV_HOME)s/wait_for_deps.sh + python3 %(ENV_HOME)s/manage.py rqworker -v 3 chunks + --worker-class cvat.rqworker.DefaultWorker +environment=VECTOR_EVENT_HANDLER="SynchronousLogstashHandler",CVAT_POSTGRES_APPLICATION_NAME="cvat:worker:chunks" +numprocs=%(ENV_NUMPROCS)s +process_name=%(program_name)s-%(process_num)d +autorestart=true + +[program:smokescreen] +command=smokescreen --listen-ip=127.0.0.1 %(ENV_SMOKESCREEN_OPTS)s diff --git a/tests/docker-compose.file_share.yml b/tests/docker-compose.file_share.yml index 3ceeb355f687..bca485ad48c8 100644 --- a/tests/docker-compose.file_share.yml +++ b/tests/docker-compose.file_share.yml @@ -5,3 +5,6 @@ services: cvat_server: volumes: - ./tests/mounted_file_share:/home/django/share:rw + cvat_worker_chunks: + volumes: + - ./tests/mounted_file_share:/home/django/share:rw diff --git a/tests/docker-compose.minio.yml b/tests/docker-compose.minio.yml index 6f82aadd1806..6089aa69f8bf 100644 --- a/tests/docker-compose.minio.yml +++ b/tests/docker-compose.minio.yml @@ -8,6 +8,7 @@ services: cvat_server: *allow-minio cvat_worker_export: *allow-minio cvat_worker_import: *allow-minio + cvat_worker_chunks: *allow-minio minio: image: quay.io/minio/minio:RELEASE.2022-09-17T00-09-45Z diff --git a/tests/python/cli/test_cli.py b/tests/python/cli/test_cli.py index f57775ca67ab..8008f44270ab 100644 --- a/tests/python/cli/test_cli.py +++ b/tests/python/cli/test_cli.py @@ -27,6 +27,8 @@ class TestCLI: def setup( self, restore_db_per_function, # force fixture call order to allow DB setup + restore_redis_inmem_per_function, + restore_redis_ondisk_per_function, fxt_stdout: io.StringIO, tmp_path: Path, admin_user: str, diff --git a/tests/python/rest_api/test_jobs.py b/tests/python/rest_api/test_jobs.py index 5057f652030c..e7b405dce9e9 100644 --- a/tests/python/rest_api/test_jobs.py +++ b/tests/python/rest_api/test_jobs.py @@ -691,6 +691,7 @@ def test_get_gt_job_in_org_task( @pytest.mark.usefixtures("restore_db_per_class") @pytest.mark.usefixtures("restore_redis_ondisk_per_class") +@pytest.mark.usefixtures("restore_redis_inmem_per_class") class TestGetGtJobData: def _delete_gt_job(self, user, gt_job_id): with make_api_client(user) as api_client: diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index cf96ff50a17a..be49c9d43ca1 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -890,6 +890,7 @@ def test_can_export_task_to_coco_format(self, admin_user: str, tid: int, api_ver @pytest.mark.parametrize("api_version", (1, 2)) @pytest.mark.usefixtures("restore_db_per_function") + @pytest.mark.usefixtures("restore_redis_ondisk_per_function") def test_can_download_task_with_special_chars_in_name(self, admin_user: str, api_version: int): # Control characters in filenames may conflict with the Content-Disposition header # value restrictions, as it needs to include the downloaded file name. @@ -1016,6 +1017,7 @@ def test_datumaro_export_without_annotations_includes_image_info( @pytest.mark.usefixtures("restore_cvat_data_per_function") @pytest.mark.usefixtures("restore_redis_ondisk_per_function") @pytest.mark.usefixtures("restore_redis_ondisk_after_class") +@pytest.mark.usefixtures("restore_redis_inmem_per_function") class TestPostTaskData: _USERNAME = "admin1" @@ -2725,8 +2727,9 @@ def read_frame(self, i: int) -> Image.Image: @pytest.mark.usefixtures("restore_db_per_class") @pytest.mark.usefixtures("restore_cvat_data_per_class") -@pytest.mark.usefixtures("restore_redis_ondisk_per_class") +@pytest.mark.usefixtures("restore_redis_ondisk_per_function") @pytest.mark.usefixtures("restore_redis_ondisk_after_class") +@pytest.mark.usefixtures("restore_redis_inmem_per_function") class TestTaskData: _USERNAME = "admin1" @@ -3816,6 +3819,7 @@ def test_admin_can_add_skeleton(self, tasks, admin_user): @pytest.mark.usefixtures("restore_db_per_function") @pytest.mark.usefixtures("restore_cvat_data_per_function") @pytest.mark.usefixtures("restore_redis_ondisk_per_function") +@pytest.mark.usefixtures("restore_redis_inmem_per_function") class TestWorkWithTask: _USERNAME = "admin1" @@ -4696,7 +4700,7 @@ def test_task_unassigned_cannot_see_task_preview( self._test_assigned_users_cannot_see_task_preview(tasks, users, is_task_staff) -@pytest.mark.usefixtures("restore_redis_ondisk_per_class") +@pytest.mark.usefixtures("restore_redis_ondisk_per_function") @pytest.mark.usefixtures("restore_redis_ondisk_after_class") class TestUnequalJobs: @pytest.fixture(autouse=True) diff --git a/tests/python/sdk/test_auto_annotation.py b/tests/python/sdk/test_auto_annotation.py index ff7302c1d9c5..0d22100cfb15 100644 --- a/tests/python/sdk/test_auto_annotation.py +++ b/tests/python/sdk/test_auto_annotation.py @@ -30,6 +30,7 @@ def _common_setup( fxt_login: tuple[Client, str], fxt_logger: tuple[Logger, io.StringIO], restore_redis_ondisk_per_function, + restore_redis_inmem_per_function, ): logger = fxt_logger[0] client = fxt_login[0] diff --git a/tests/python/sdk/test_datasets.py b/tests/python/sdk/test_datasets.py index 525082d0eae3..7f13e75ea92f 100644 --- a/tests/python/sdk/test_datasets.py +++ b/tests/python/sdk/test_datasets.py @@ -23,6 +23,7 @@ def _common_setup( fxt_login: tuple[Client, str], fxt_logger: tuple[Logger, io.StringIO], restore_redis_ondisk_per_function, + restore_redis_inmem_per_function, ): logger = fxt_logger[0] client = fxt_login[0] diff --git a/tests/python/sdk/test_pytorch.py b/tests/python/sdk/test_pytorch.py index 8e6918abf301..1427a070d46b 100644 --- a/tests/python/sdk/test_pytorch.py +++ b/tests/python/sdk/test_pytorch.py @@ -36,6 +36,7 @@ def _common_setup( fxt_login: tuple[Client, str], fxt_logger: tuple[Logger, io.StringIO], restore_redis_ondisk_per_function, + restore_redis_inmem_per_function, ): logger = fxt_logger[0] client = fxt_login[0] From 86deaff93bfa9b2921b171a1dcb09df3cbedc29e Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Thu, 28 Nov 2024 19:21:42 +0200 Subject: [PATCH 094/163] Make SDK models pickleable (#8746) Currently, they aren't (or rather, they can be pickled, but unpickling fails). This is due to a small quirk of how the model classes work, and is easily worked around. In addition, don't create a new Configuration object for each model. These objects are pretty beefy, and they increase the size of each pickle by a full kilobyte (and of course they increase memory usage even when pickle is not involved). AFAICS, these objects are only used when assigning values to file-type fields, and it's easy enough to rewrite the logic so that it still works when the model's `_configuration` field is None. --- .../20241127_132256_roman_pickle_models.md | 4 ++ ...method_from_openapi_data_composed.mustache | 3 +- .../method_from_openapi_data_shared.mustache | 3 +- .../method_from_openapi_data_simple.mustache | 3 +- .../method_init_shared.mustache | 3 +- .../method_init_simple.mustache | 3 +- .../openapi-generator/model_utils.mustache | 41 ++++++++++--------- tests/python/sdk/test_api_wrappers.py | 10 +++++ 8 files changed, 40 insertions(+), 30 deletions(-) create mode 100644 changelog.d/20241127_132256_roman_pickle_models.md diff --git a/changelog.d/20241127_132256_roman_pickle_models.md b/changelog.d/20241127_132256_roman_pickle_models.md new file mode 100644 index 000000000000..0541d7ff6942 --- /dev/null +++ b/changelog.d/20241127_132256_roman_pickle_models.md @@ -0,0 +1,4 @@ +### Added + +- \[SDK\] Model instances can now be pickled + () diff --git a/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_composed.mustache b/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_composed.mustache index 97d3cb930c27..e56437b401ee 100644 --- a/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_composed.mustache +++ b/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_composed.mustache @@ -22,7 +22,6 @@ {{name}} ({{{dataType}}}):{{#description}} {{{.}}}.{{/description}} [optional]{{#defaultValue}} if omitted the server will use the default value of {{{.}}}{{/defaultValue}} # noqa: E501 {{/optionalVars}} """ - from {{packageName}}.configuration import Configuration {{#requiredVars}} {{#defaultValue}} @@ -32,7 +31,7 @@ _check_type = kwargs.pop('_check_type', True) _spec_property_naming = kwargs.pop('_spec_property_naming', False) _path_to_item = kwargs.pop('_path_to_item', ()) - _configuration = kwargs.pop('_configuration', Configuration()) + _configuration = kwargs.pop('_configuration', None) _visited_composed_classes = kwargs.pop('_visited_composed_classes', ()) self = super(OpenApiModel, cls).__new__(cls) diff --git a/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_shared.mustache b/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_shared.mustache index 4c149f22ce88..12dbba9ac641 100644 --- a/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_shared.mustache +++ b/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_shared.mustache @@ -27,7 +27,6 @@ {{/optionalVars}} {{> model_templates/docstring_init_required_kwargs }} """ - from {{packageName}}.configuration import Configuration {{#requiredVars}} {{#defaultValue}} @@ -37,7 +36,7 @@ _check_type = kwargs.pop('_check_type', True) _spec_property_naming = kwargs.pop('_spec_property_naming', True) _path_to_item = kwargs.pop('_path_to_item', ()) - _configuration = kwargs.pop('_configuration', Configuration()) + _configuration = kwargs.pop('_configuration', None) _visited_composed_classes = kwargs.pop('_visited_composed_classes', ()) self = super(OpenApiModel, cls).__new__(cls) diff --git a/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_simple.mustache b/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_simple.mustache index 853532e9f5ca..e8daa85e829c 100644 --- a/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_simple.mustache +++ b/cvat-sdk/gen/templates/openapi-generator/model_templates/method_from_openapi_data_simple.mustache @@ -12,7 +12,6 @@ value ({{{dataType}}}):{{#description}} {{{.}}}.{{/description}}{{#defaultValue}} if omitted defaults to {{{.}}}{{/defaultValue}}{{#allowableValues}}, must be one of [{{#enumVars}}{{{value}}}, {{/enumVars}}]{{/allowableValues}} # noqa: E501 {{> model_templates/docstring_init_required_kwargs }} """ - from {{packageName}}.configuration import Configuration # required up here when default value is not given _path_to_item = kwargs.pop('_path_to_item', ()) @@ -39,7 +38,7 @@ _check_type = kwargs.pop('_check_type', True) _spec_property_naming = kwargs.pop('_spec_property_naming', False) - _configuration = kwargs.pop('_configuration', Configuration()) + _configuration = kwargs.pop('_configuration', None) _visited_composed_classes = kwargs.pop('_visited_composed_classes', ()) {{> model_templates/invalid_pos_args }} diff --git a/cvat-sdk/gen/templates/openapi-generator/model_templates/method_init_shared.mustache b/cvat-sdk/gen/templates/openapi-generator/model_templates/method_init_shared.mustache index 998b4841b7e7..c7d402a6cc52 100644 --- a/cvat-sdk/gen/templates/openapi-generator/model_templates/method_init_shared.mustache +++ b/cvat-sdk/gen/templates/openapi-generator/model_templates/method_init_shared.mustache @@ -30,7 +30,6 @@ {{/optionalVars}} {{> model_templates/docstring_init_required_kwargs }} """ - from {{packageName}}.configuration import Configuration {{#requiredVars}} {{^isReadOnly}} @@ -42,7 +41,7 @@ _check_type = kwargs.pop('_check_type', True) _spec_property_naming = kwargs.pop('_spec_property_naming', False) _path_to_item = kwargs.pop('_path_to_item', ()) - _configuration = kwargs.pop('_configuration', Configuration()) + _configuration = kwargs.pop('_configuration', None) _visited_composed_classes = kwargs.pop('_visited_composed_classes', ()) {{> model_templates/invalid_pos_args }} diff --git a/cvat-sdk/gen/templates/openapi-generator/model_templates/method_init_simple.mustache b/cvat-sdk/gen/templates/openapi-generator/model_templates/method_init_simple.mustache index 8c8b42ce1f49..424b1d439c62 100644 --- a/cvat-sdk/gen/templates/openapi-generator/model_templates/method_init_simple.mustache +++ b/cvat-sdk/gen/templates/openapi-generator/model_templates/method_init_simple.mustache @@ -20,7 +20,6 @@ value ({{{dataType}}}):{{#description}} {{{.}}}.{{/description}}{{#defaultValue}} if omitted defaults to {{{.}}}{{/defaultValue}}{{#allowableValues}}, must be one of [{{#enumVars}}{{{value}}}, {{/enumVars}}]{{/allowableValues}} # noqa: E501 {{> model_templates/docstring_init_required_kwargs }} """ - from {{packageName}}.configuration import Configuration # required up here when default value is not given _path_to_item = kwargs.pop('_path_to_item', ()) @@ -45,7 +44,7 @@ _check_type = kwargs.pop('_check_type', True) _spec_property_naming = kwargs.pop('_spec_property_naming', False) - _configuration = kwargs.pop('_configuration', Configuration()) + _configuration = kwargs.pop('_configuration', None) _visited_composed_classes = kwargs.pop('_visited_composed_classes', ()) {{> model_templates/invalid_pos_args }} diff --git a/cvat-sdk/gen/templates/openapi-generator/model_utils.mustache b/cvat-sdk/gen/templates/openapi-generator/model_utils.mustache index c9e2b70d77bb..cc3c03dbce77 100644 --- a/cvat-sdk/gen/templates/openapi-generator/model_utils.mustache +++ b/cvat-sdk/gen/templates/openapi-generator/model_utils.mustache @@ -354,6 +354,13 @@ class OpenApiModel(object): new_inst = new_cls._new_from_openapi_data(*args, **kwargs) return new_inst + def __setstate__(self, state): + # This is the same as the default implementation. We override it, + # because unpickling attempts to access `obj.__setstate__` on an uninitialized + # object, and if this method is not defined, it results in a call to `__getattr__`. + # This fails, because `__getattr__` relies on `self._data_store`, which doesn't + # exist in an uninitialized object. + self.__dict__.update(state) class ModelSimple(OpenApiModel): """the parent class of models whose type != object in their @@ -1084,7 +1091,7 @@ def deserialize_file(response_data, configuration, content_disposition=None): (file_type): the deserialized file which is open The user is responsible for closing and reading the file """ - fd, path = tempfile.mkstemp(dir=configuration.temp_folder_path) + fd, path = tempfile.mkstemp(dir=configuration.temp_folder_path if configuration else None) os.close(fd) os.remove(path) @@ -1263,27 +1270,21 @@ def validate_and_convert_types(input_value, required_types_mixed, path_to_item, input_class_simple = get_simple_class(input_value) valid_type = is_valid_type(input_class_simple, valid_classes) if not valid_type: - if (configuration - or (input_class_simple == dict - and dict not in valid_classes)): - # if input_value is not valid_type try to convert it - converted_instance = attempt_convert_item( - input_value, - valid_classes, - path_to_item, - configuration, - spec_property_naming, - key_type=False, - must_convert=True, - check_type=_check_type - ) - return converted_instance - else: - raise get_type_error(input_value, path_to_item, valid_classes, - key_type=False) + # if input_value is not valid_type try to convert it + converted_instance = attempt_convert_item( + input_value, + valid_classes, + path_to_item, + configuration, + spec_property_naming, + key_type=False, + must_convert=True, + check_type=_check_type + ) + return converted_instance # input_value's type is in valid_classes - if len(valid_classes) > 1 and configuration: + if len(valid_classes) > 1: # there are valid classes which are not the current class valid_classes_coercible = remove_uncoercible( valid_classes, input_value, spec_property_naming, must_convert=False) diff --git a/tests/python/sdk/test_api_wrappers.py b/tests/python/sdk/test_api_wrappers.py index 84ec919c9ba2..f324637b78e9 100644 --- a/tests/python/sdk/test_api_wrappers.py +++ b/tests/python/sdk/test_api_wrappers.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: MIT +import pickle from copy import deepcopy from cvat_sdk import models @@ -112,3 +113,12 @@ def test_models_do_not_return_internal_collections(): model_data2 = model.to_dict() assert DeepDiff(model_data1_original, model_data2) == {} + + +def test_models_are_pickleable(): + model = models.PatchedLabelRequest(id=5, name="person") + pickled_model = pickle.dumps(model) + unpickled_model = pickle.loads(pickled_model) + + assert unpickled_model.id == model.id + assert unpickled_model.name == model.name From bc9c1bcf1538fb6e4a365af3c4e5bba3271da38a Mon Sep 17 00:00:00 2001 From: "cvat-bot[bot]" <147643061+cvat-bot[bot]@users.noreply.github.com> Date: Fri, 29 Nov 2024 07:19:43 +0000 Subject: [PATCH 095/163] Prepare release v2.23.0 --- CHANGELOG.md | 106 ++++++++++++++++++ ...231110_175126_mzhiltso_update_dm_format.md | 9 -- ...0241107_154537_andrey_worker_for_chunks.md | 4 - ..._andrey_disable_traefik_sticky_sessions.md | 4 - ...135556_klakhov_fix_inconsistent_z_order.md | 4 - .../20241111_142755_klakhov_fix_show_gt.md | 4 - ...1111_195229_roman_remove_lambda_quality.md | 5 - ...1112_132508_klakhov_fix_remember_object.md | 4 - .../20241112_201034_roman_aa_threshold.md | 12 -- ...5531_dmitrii.lavrukhin_minimize_payload.md | 4 - ...5732_dmitrii.lavrukhin_minimize_payload.md | 4 - .../20241114_123836_klakhov_gt_issues.md | 6 - changelog.d/20241120_143739_roman_aa_masks.md | 13 --- changelog.d/20241120_172837_roman.md | 4 - ...sekachev.bs_updated_annotations_actions.md | 4 - ...sekachev.bs_updated_annotations_actions.md | 4 - ...sekachev.bs_updated_annotations_actions.md | 4 - ...sekachev.bs_updated_annotations_actions.md | 4 - changelog.d/20241121_013447_sekachev.bs.md | 4 - changelog.d/20241121_013934_sekachev.bs.md | 4 - ...1125_193231_rragundez_ldap_default_role.md | 3 - ...6_140417_roman_rename_conv_mask_to_poly.md | 11 -- .../20241127_132256_roman_pickle_models.md | 4 - cvat-cli/requirements/base.txt | 2 +- cvat-cli/src/cvat_cli/version.py | 2 +- cvat-sdk/gen/generate.sh | 2 +- cvat/__init__.py | 2 +- cvat/schema.yml | 2 +- docker-compose.yml | 20 ++-- helm-chart/values.yaml | 4 +- 30 files changed, 123 insertions(+), 136 deletions(-) delete mode 100644 changelog.d/20231110_175126_mzhiltso_update_dm_format.md delete mode 100644 changelog.d/20241107_154537_andrey_worker_for_chunks.md delete mode 100644 changelog.d/20241107_162818_andrey_disable_traefik_sticky_sessions.md delete mode 100644 changelog.d/20241108_135556_klakhov_fix_inconsistent_z_order.md delete mode 100644 changelog.d/20241111_142755_klakhov_fix_show_gt.md delete mode 100644 changelog.d/20241111_195229_roman_remove_lambda_quality.md delete mode 100644 changelog.d/20241112_132508_klakhov_fix_remember_object.md delete mode 100644 changelog.d/20241112_201034_roman_aa_threshold.md delete mode 100644 changelog.d/20241113_125531_dmitrii.lavrukhin_minimize_payload.md delete mode 100644 changelog.d/20241113_125732_dmitrii.lavrukhin_minimize_payload.md delete mode 100644 changelog.d/20241114_123836_klakhov_gt_issues.md delete mode 100644 changelog.d/20241120_143739_roman_aa_masks.md delete mode 100644 changelog.d/20241120_172837_roman.md delete mode 100644 changelog.d/20241120_234543_sekachev.bs_updated_annotations_actions.md delete mode 100644 changelog.d/20241120_234732_sekachev.bs_updated_annotations_actions.md delete mode 100644 changelog.d/20241120_234852_sekachev.bs_updated_annotations_actions.md delete mode 100644 changelog.d/20241121_005939_sekachev.bs_updated_annotations_actions.md delete mode 100644 changelog.d/20241121_013447_sekachev.bs.md delete mode 100644 changelog.d/20241121_013934_sekachev.bs.md delete mode 100644 changelog.d/20241125_193231_rragundez_ldap_default_role.md delete mode 100644 changelog.d/20241126_140417_roman_rename_conv_mask_to_poly.md delete mode 100644 changelog.d/20241127_132256_roman_pickle_models.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c65844cd40..a9143f436f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,112 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + +## \[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 diff --git a/changelog.d/20231110_175126_mzhiltso_update_dm_format.md b/changelog.d/20231110_175126_mzhiltso_update_dm_format.md deleted file mode 100644 index 2aed7d7c8759..000000000000 --- a/changelog.d/20231110_175126_mzhiltso_update_dm_format.md +++ /dev/null @@ -1,9 +0,0 @@ -### Added - -- Support for direct .json file import in Datumaro format - () - -### Fixed - -- Export without images in Datumaro format should include image info - () diff --git a/changelog.d/20241107_154537_andrey_worker_for_chunks.md b/changelog.d/20241107_154537_andrey_worker_for_chunks.md deleted file mode 100644 index 64ee2d5c4f34..000000000000 --- a/changelog.d/20241107_154537_andrey_worker_for_chunks.md +++ /dev/null @@ -1,4 +0,0 @@ -### Changed - -- Chunks are now prepared in a separate worker process - () diff --git a/changelog.d/20241107_162818_andrey_disable_traefik_sticky_sessions.md b/changelog.d/20241107_162818_andrey_disable_traefik_sticky_sessions.md deleted file mode 100644 index 1f8d81c6b6fa..000000000000 --- a/changelog.d/20241107_162818_andrey_disable_traefik_sticky_sessions.md +++ /dev/null @@ -1,4 +0,0 @@ -### Changed - -- \[Helm\] Traefik sticky sessions for the backend service are disabled - () diff --git a/changelog.d/20241108_135556_klakhov_fix_inconsistent_z_order.md b/changelog.d/20241108_135556_klakhov_fix_inconsistent_z_order.md deleted file mode 100644 index 72e78a1447cb..000000000000 --- a/changelog.d/20241108_135556_klakhov_fix_inconsistent_z_order.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Inconsistent zOrder behavior on job open - () diff --git a/changelog.d/20241111_142755_klakhov_fix_show_gt.md b/changelog.d/20241111_142755_klakhov_fix_show_gt.md deleted file mode 100644 index 7a687595a21f..000000000000 --- a/changelog.d/20241111_142755_klakhov_fix_show_gt.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Ground truth annotations can be shown in standard mode - () diff --git a/changelog.d/20241111_195229_roman_remove_lambda_quality.md b/changelog.d/20241111_195229_roman_remove_lambda_quality.md deleted file mode 100644 index 8d744027f30b..000000000000 --- a/changelog.d/20241111_195229_roman_remove_lambda_quality.md +++ /dev/null @@ -1,5 +0,0 @@ -### Removed - -- It it no longer possible to run lambda functions on compressed images; - original images will always be used - () diff --git a/changelog.d/20241112_132508_klakhov_fix_remember_object.md b/changelog.d/20241112_132508_klakhov_fix_remember_object.md deleted file mode 100644 index fd3f7bd37eae..000000000000 --- a/changelog.d/20241112_132508_klakhov_fix_remember_object.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Keybinds in UI allow drawing disabled shape types - () diff --git a/changelog.d/20241112_201034_roman_aa_threshold.md b/changelog.d/20241112_201034_roman_aa_threshold.md deleted file mode 100644 index 0a1da765badb..000000000000 --- a/changelog.d/20241112_201034_roman_aa_threshold.md +++ /dev/null @@ -1,12 +0,0 @@ -### Added - -- \[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 - () diff --git a/changelog.d/20241113_125531_dmitrii.lavrukhin_minimize_payload.md b/changelog.d/20241113_125531_dmitrii.lavrukhin_minimize_payload.md deleted file mode 100644 index b84c500455e5..000000000000 --- a/changelog.d/20241113_125531_dmitrii.lavrukhin_minimize_payload.md +++ /dev/null @@ -1,4 +0,0 @@ -### Added - -- New events (create|update|delete):(membership|webhook) and (create|delete):invitation - () diff --git a/changelog.d/20241113_125732_dmitrii.lavrukhin_minimize_payload.md b/changelog.d/20241113_125732_dmitrii.lavrukhin_minimize_payload.md deleted file mode 100644 index 6001bd280d18..000000000000 --- a/changelog.d/20241113_125732_dmitrii.lavrukhin_minimize_payload.md +++ /dev/null @@ -1,4 +0,0 @@ -### Changed - -- Payload for events (create|update|delete):(shapes|tags|tracks) does not include frame and attributes anymore - () diff --git a/changelog.d/20241114_123836_klakhov_gt_issues.md b/changelog.d/20241114_123836_klakhov_gt_issues.md deleted file mode 100644 index 9ebd5f388118..000000000000 --- a/changelog.d/20241114_123836_klakhov_gt_issues.md +++ /dev/null @@ -1,6 +0,0 @@ -### Fixed - -- Style issues on the Quality page when browser zoom is applied - () -- Flickering of masks in review mode, even when no conflicts are highlighted - () diff --git a/changelog.d/20241120_143739_roman_aa_masks.md b/changelog.d/20241120_143739_roman_aa_masks.md deleted file mode 100644 index 97422dfe6060..000000000000 --- a/changelog.d/20241120_143739_roman_aa_masks.md +++ /dev/null @@ -1,13 +0,0 @@ -### Added - -- \[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) - () diff --git a/changelog.d/20241120_172837_roman.md b/changelog.d/20241120_172837_roman.md deleted file mode 100644 index 1b6e1cc64f16..000000000000 --- a/changelog.d/20241120_172837_roman.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Fixed security header duplication in HTTP responses from the backend - () diff --git a/changelog.d/20241120_234543_sekachev.bs_updated_annotations_actions.md b/changelog.d/20241120_234543_sekachev.bs_updated_annotations_actions.md deleted file mode 100644 index f29d658fa5a2..000000000000 --- a/changelog.d/20241120_234543_sekachev.bs_updated_annotations_actions.md +++ /dev/null @@ -1,4 +0,0 @@ -### Added - -- A user may undo or redo changes, made by an annotations actions using general approach (e.g. Ctrl+Z, Ctrl+Y) - () diff --git a/changelog.d/20241120_234732_sekachev.bs_updated_annotations_actions.md b/changelog.d/20241120_234732_sekachev.bs_updated_annotations_actions.md deleted file mode 100644 index a935397785cc..000000000000 --- a/changelog.d/20241120_234732_sekachev.bs_updated_annotations_actions.md +++ /dev/null @@ -1,4 +0,0 @@ -### Added - -- Basically, annotations actions now support any kinds of objects (shapes, tracks, tags) - () diff --git a/changelog.d/20241120_234852_sekachev.bs_updated_annotations_actions.md b/changelog.d/20241120_234852_sekachev.bs_updated_annotations_actions.md deleted file mode 100644 index 802b137fa343..000000000000 --- a/changelog.d/20241120_234852_sekachev.bs_updated_annotations_actions.md +++ /dev/null @@ -1,4 +0,0 @@ -### Added - -- A user may run annotations actions on a certain object (added corresponding object menu item) - () diff --git a/changelog.d/20241121_005939_sekachev.bs_updated_annotations_actions.md b/changelog.d/20241121_005939_sekachev.bs_updated_annotations_actions.md deleted file mode 100644 index 16fbffde424f..000000000000 --- a/changelog.d/20241121_005939_sekachev.bs_updated_annotations_actions.md +++ /dev/null @@ -1,4 +0,0 @@ -### Added - -- A shortcut to open annotations actions modal for a currently selected object - () diff --git a/changelog.d/20241121_013447_sekachev.bs.md b/changelog.d/20241121_013447_sekachev.bs.md deleted file mode 100644 index 47e7300bd071..000000000000 --- a/changelog.d/20241121_013447_sekachev.bs.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- The error occurs when trying to copy/paste a mask on a video after opening the job - () diff --git a/changelog.d/20241121_013934_sekachev.bs.md b/changelog.d/20241121_013934_sekachev.bs.md deleted file mode 100644 index ce0410db76ea..000000000000 --- a/changelog.d/20241121_013934_sekachev.bs.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Attributes do not get copied when copy/paste a mask - () diff --git a/changelog.d/20241125_193231_rragundez_ldap_default_role.md b/changelog.d/20241125_193231_rragundez_ldap_default_role.md deleted file mode 100644 index 2939d1e0750e..000000000000 --- a/changelog.d/20241125_193231_rragundez_ldap_default_role.md +++ /dev/null @@ -1,3 +0,0 @@ -### Added - -- A default role if IAM_TYPE='LDAP' and if the user is not a member of any group in 'DJANGO_AUTH_LDAP_GROUPS' () diff --git a/changelog.d/20241126_140417_roman_rename_conv_mask_to_poly.md b/changelog.d/20241126_140417_roman_rename_conv_mask_to_poly.md deleted file mode 100644 index 1788c1fe6c41..000000000000 --- a/changelog.d/20241126_140417_roman_rename_conv_mask_to_poly.md +++ /dev/null @@ -1,11 +0,0 @@ -### Added - -- The `POST /api/lambda/requests` endpoint now has a `conv_mask_to_poly` - parameter with the same semantics as the old `convMaskToPoly` parameter - () - -### Deprecated - -- The `convMaskToPoly` parameter of the `POST /api/lambda/requests` endpoint - is deprecated; use `conv_mask_to_poly` instead - () diff --git a/changelog.d/20241127_132256_roman_pickle_models.md b/changelog.d/20241127_132256_roman_pickle_models.md deleted file mode 100644 index 0541d7ff6942..000000000000 --- a/changelog.d/20241127_132256_roman_pickle_models.md +++ /dev/null @@ -1,4 +0,0 @@ -### Added - -- \[SDK\] Model instances can now be pickled - () diff --git a/cvat-cli/requirements/base.txt b/cvat-cli/requirements/base.txt index 31e7bb5d1fd0..5f27832efdb7 100644 --- a/cvat-cli/requirements/base.txt +++ b/cvat-cli/requirements/base.txt @@ -1,3 +1,3 @@ -cvat-sdk~=2.22.1 +cvat-sdk~=2.23.0 Pillow>=10.3.0 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/cvat-cli/src/cvat_cli/version.py b/cvat-cli/src/cvat_cli/version.py index 88bee3fd182f..9b4fa879ca12 100644 --- a/cvat-cli/src/cvat_cli/version.py +++ b/cvat-cli/src/cvat_cli/version.py @@ -1 +1 @@ -VERSION = "2.22.1" +VERSION = "2.23.0" diff --git a/cvat-sdk/gen/generate.sh b/cvat-sdk/gen/generate.sh index 8be759fda9fe..60875c499496 100755 --- a/cvat-sdk/gen/generate.sh +++ b/cvat-sdk/gen/generate.sh @@ -8,7 +8,7 @@ set -e GENERATOR_VERSION="v6.0.1" -VERSION="2.22.1" +VERSION="2.23.0" LIB_NAME="cvat_sdk" LAYER1_LIB_NAME="${LIB_NAME}/api_client" DST_DIR="$(cd "$(dirname -- "$0")/.." && pwd)" diff --git a/cvat/__init__.py b/cvat/__init__.py index fbc88c435f3c..7474e260b0e1 100644 --- a/cvat/__init__.py +++ b/cvat/__init__.py @@ -4,6 +4,6 @@ from cvat.utils.version import get_version -VERSION = (2, 22, 1, "alpha", 0) +VERSION = (2, 23, 0, "final", 0) __version__ = get_version(VERSION) diff --git a/cvat/schema.yml b/cvat/schema.yml index b8d02651ffc6..ad8809e93111 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: CVAT REST API - version: 2.22.1 + version: 2.23.0 description: REST API for Computer Vision Annotation Tool (CVAT) termsOfService: https://www.google.com/policies/terms/ contact: diff --git a/docker-compose.yml b/docker-compose.yml index a921b70cbf9f..bed3fdae6255 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,7 @@ services: cvat_server: container_name: cvat_server - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.0} restart: always depends_on: <<: *backend-deps @@ -113,7 +113,7 @@ services: cvat_utils: container_name: cvat_utils - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.0} restart: always depends_on: *backend-deps environment: @@ -130,7 +130,7 @@ services: cvat_worker_import: container_name: cvat_worker_import - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.0} restart: always depends_on: *backend-deps environment: @@ -146,7 +146,7 @@ services: cvat_worker_export: container_name: cvat_worker_export - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.0} restart: always depends_on: *backend-deps environment: @@ -162,7 +162,7 @@ services: cvat_worker_annotation: container_name: cvat_worker_annotation - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.0} restart: always depends_on: *backend-deps environment: @@ -178,7 +178,7 @@ services: cvat_worker_webhooks: container_name: cvat_worker_webhooks - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.0} restart: always depends_on: *backend-deps environment: @@ -194,7 +194,7 @@ services: cvat_worker_quality_reports: container_name: cvat_worker_quality_reports - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.0} restart: always depends_on: *backend-deps environment: @@ -210,7 +210,7 @@ services: cvat_worker_analytics_reports: container_name: cvat_worker_analytics_reports - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.0} restart: always depends_on: *backend-deps environment: @@ -226,7 +226,7 @@ services: cvat_worker_chunks: container_name: cvat_worker_chunks - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.0} restart: always depends_on: *backend-deps environment: @@ -242,7 +242,7 @@ services: cvat_ui: container_name: cvat_ui - image: cvat/ui:${CVAT_VERSION:-dev} + image: cvat/ui:${CVAT_VERSION:-v2.23.0} restart: always depends_on: - cvat_server diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index ae0180efd972..2e36950b3f2a 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -139,7 +139,7 @@ cvat: additionalVolumeMounts: [] replicas: 1 image: cvat/server - tag: dev + tag: v2.23.0 imagePullPolicy: Always permissionFix: enabled: true @@ -162,7 +162,7 @@ cvat: frontend: replicas: 1 image: cvat/ui - tag: dev + tag: v2.23.0 imagePullPolicy: Always labels: {} # test: test From 05a39d670f39f3e02f2c21fdde5c7230f1cca163 Mon Sep 17 00:00:00 2001 From: "cvat-bot[bot]" <147643061+cvat-bot[bot]@users.noreply.github.com> Date: Fri, 29 Nov 2024 08:20:14 +0000 Subject: [PATCH 096/163] Update develop after v2.23.0 --- cvat-cli/requirements/base.txt | 2 +- cvat-cli/src/cvat_cli/version.py | 2 +- cvat-sdk/gen/generate.sh | 2 +- cvat/__init__.py | 2 +- cvat/schema.yml | 2 +- docker-compose.yml | 20 ++++++++++---------- helm-chart/values.yaml | 4 ++-- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cvat-cli/requirements/base.txt b/cvat-cli/requirements/base.txt index 5f27832efdb7..8793644b6339 100644 --- a/cvat-cli/requirements/base.txt +++ b/cvat-cli/requirements/base.txt @@ -1,3 +1,3 @@ -cvat-sdk~=2.23.0 +cvat-sdk~=2.23.1 Pillow>=10.3.0 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/cvat-cli/src/cvat_cli/version.py b/cvat-cli/src/cvat_cli/version.py index 9b4fa879ca12..c642e25a75ea 100644 --- a/cvat-cli/src/cvat_cli/version.py +++ b/cvat-cli/src/cvat_cli/version.py @@ -1 +1 @@ -VERSION = "2.23.0" +VERSION = "2.23.1" diff --git a/cvat-sdk/gen/generate.sh b/cvat-sdk/gen/generate.sh index 60875c499496..de41d2f680cb 100755 --- a/cvat-sdk/gen/generate.sh +++ b/cvat-sdk/gen/generate.sh @@ -8,7 +8,7 @@ set -e GENERATOR_VERSION="v6.0.1" -VERSION="2.23.0" +VERSION="2.23.1" LIB_NAME="cvat_sdk" LAYER1_LIB_NAME="${LIB_NAME}/api_client" DST_DIR="$(cd "$(dirname -- "$0")/.." && pwd)" diff --git a/cvat/__init__.py b/cvat/__init__.py index 7474e260b0e1..cb5542641a13 100644 --- a/cvat/__init__.py +++ b/cvat/__init__.py @@ -4,6 +4,6 @@ from cvat.utils.version import get_version -VERSION = (2, 23, 0, "final", 0) +VERSION = (2, 23, 1, "alpha", 0) __version__ = get_version(VERSION) diff --git a/cvat/schema.yml b/cvat/schema.yml index ad8809e93111..44e5f19ca2dc 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: CVAT REST API - version: 2.23.0 + version: 2.23.1 description: REST API for Computer Vision Annotation Tool (CVAT) termsOfService: https://www.google.com/policies/terms/ contact: diff --git a/docker-compose.yml b/docker-compose.yml index bed3fdae6255..a921b70cbf9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,7 @@ services: cvat_server: container_name: cvat_server - image: cvat/server:${CVAT_VERSION:-v2.23.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: <<: *backend-deps @@ -113,7 +113,7 @@ services: cvat_utils: container_name: cvat_utils - image: cvat/server:${CVAT_VERSION:-v2.23.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -130,7 +130,7 @@ services: cvat_worker_import: container_name: cvat_worker_import - image: cvat/server:${CVAT_VERSION:-v2.23.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -146,7 +146,7 @@ services: cvat_worker_export: container_name: cvat_worker_export - image: cvat/server:${CVAT_VERSION:-v2.23.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -162,7 +162,7 @@ services: cvat_worker_annotation: container_name: cvat_worker_annotation - image: cvat/server:${CVAT_VERSION:-v2.23.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -178,7 +178,7 @@ services: cvat_worker_webhooks: container_name: cvat_worker_webhooks - image: cvat/server:${CVAT_VERSION:-v2.23.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -194,7 +194,7 @@ services: cvat_worker_quality_reports: container_name: cvat_worker_quality_reports - image: cvat/server:${CVAT_VERSION:-v2.23.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -210,7 +210,7 @@ services: cvat_worker_analytics_reports: container_name: cvat_worker_analytics_reports - image: cvat/server:${CVAT_VERSION:-v2.23.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -226,7 +226,7 @@ services: cvat_worker_chunks: container_name: cvat_worker_chunks - image: cvat/server:${CVAT_VERSION:-v2.23.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -242,7 +242,7 @@ services: cvat_ui: container_name: cvat_ui - image: cvat/ui:${CVAT_VERSION:-v2.23.0} + image: cvat/ui:${CVAT_VERSION:-dev} restart: always depends_on: - cvat_server diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 2e36950b3f2a..ae0180efd972 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -139,7 +139,7 @@ cvat: additionalVolumeMounts: [] replicas: 1 image: cvat/server - tag: v2.23.0 + tag: dev imagePullPolicy: Always permissionFix: enabled: true @@ -162,7 +162,7 @@ cvat: frontend: replicas: 1 image: cvat/ui - tag: v2.23.0 + tag: dev imagePullPolicy: Always labels: {} # test: test From 4681dd651d812d1adee3c14fcdaa889c8c5498d6 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Mon, 2 Dec 2024 18:12:24 +0200 Subject: [PATCH 097/163] Removed outdated code related to web analytics (#8755) --- Dockerfile.ui | 1 - cvat-ui/src/components/cvat-app.tsx | 5 +---- cvat-ui/src/utils/environment.ts | 22 +--------------------- 3 files changed, 2 insertions(+), 26 deletions(-) diff --git a/Dockerfile.ui b/Dockerfile.ui index da9c36d38960..f134f5d62883 100644 --- a/Dockerfile.ui +++ b/Dockerfile.ui @@ -24,7 +24,6 @@ COPY cvat-canvas3d/ /tmp/cvat-canvas3d/ COPY cvat-canvas/ /tmp/cvat-canvas/ COPY cvat-ui/ /tmp/cvat-ui/ -ARG WA_PAGE_VIEW_HIT ARG UI_APP_CONFIG ARG CLIENT_PLUGINS ARG DISABLE_SOURCE_MAPS diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index ff9d2e1637b3..ef2fe1a824da 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -65,7 +65,6 @@ import { Organization, getCore } from 'cvat-core-wrapper'; import { ErrorState, NotificationState, NotificationsState, PluginsState, } from 'reducers'; -import { customWaViewHit } from 'utils/environment'; import showPlatformNotification, { platformInfo, stopNotifications, @@ -142,7 +141,7 @@ class CVATApplication extends React.PureComponent { - customWaViewHit(newLocation.pathname, newLocation.search, newLocation.hash); const { location: prevLocation } = this.props; onChangeLocation(prevLocation.pathname, newLocation.pathname); diff --git a/cvat-ui/src/utils/environment.ts b/cvat-ui/src/utils/environment.ts index 9f73417199c7..c757afe8e0c6 100644 --- a/cvat-ui/src/utils/environment.ts +++ b/cvat-ui/src/utils/environment.ts @@ -1,5 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation -// Copyright (C) 2022 CVAT.ai Corp +// Copyright (C) 2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -9,23 +9,3 @@ export function isDev(): boolean { return process.env.NODE_ENV === 'development'; } - -export function customWaViewHit(pageName?: string, queryString?: string, hashInfo?: string): void { - const waHitFunctionName = process.env.WA_PAGE_VIEW_HIT; - if (waHitFunctionName) { - const waHitFunction = new Function( - 'pageName', - 'queryString', - 'hashInfo', - `if (typeof ${waHitFunctionName} === 'function') { - ${waHitFunctionName}(pageName, queryString, hashInfo); - }`, - ); - try { - waHitFunction(pageName, queryString, hashInfo); - } catch (error: any) { - // eslint-disable-next-line - console.error(`Web analytics hit function has failed. ${error.toString()}`); - } - } -} From 73cdceb9321b204ff12d768f3dda3c611bc480ca Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Mon, 2 Dec 2024 18:12:38 +0200 Subject: [PATCH 098/163] Fixed color of 'Create object URL' on not saved object (#8752) --- ...20241128_131448_sekachev.bs_fixed_create_obj_url_color.md | 4 ++++ .../standard-workspace/objects-side-bar/styles.scss | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20241128_131448_sekachev.bs_fixed_create_obj_url_color.md diff --git a/changelog.d/20241128_131448_sekachev.bs_fixed_create_obj_url_color.md b/changelog.d/20241128_131448_sekachev.bs_fixed_create_obj_url_color.md new file mode 100644 index 000000000000..2e80a5343c99 --- /dev/null +++ b/changelog.d/20241128_131448_sekachev.bs_fixed_create_obj_url_color.md @@ -0,0 +1,4 @@ +### Fixed + +- Color of 'Create object URL' button for a not saved on the server object + () diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/styles.scss b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/styles.scss index b573bb61618d..b91cc68bd8d1 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/styles.scss +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/styles.scss @@ -402,7 +402,10 @@ } button { - color: $text-color; + &:not(:disabled) { + color: $text-color; + } + width: 100%; height: 100%; text-align: left; From 86bd1f153d4c8c9a5ca649128a3aaa3eb40c595b Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Mon, 2 Dec 2024 18:12:50 +0200 Subject: [PATCH 099/163] Fixed issue: fit:canvas may not generate in some cases (#8750) --- .../20241128_112750_sekachev.bs_fixed_fit.md | 4 ++++ cvat-canvas/src/typescript/canvasModel.ts | 24 ++++++++++++------- cvat-canvas/src/typescript/canvasView.ts | 15 +++++++----- 3 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 changelog.d/20241128_112750_sekachev.bs_fixed_fit.md diff --git a/changelog.d/20241128_112750_sekachev.bs_fixed_fit.md b/changelog.d/20241128_112750_sekachev.bs_fixed_fit.md new file mode 100644 index 000000000000..cb5845dee0f7 --- /dev/null +++ b/changelog.d/20241128_112750_sekachev.bs_fixed_fit.md @@ -0,0 +1,4 @@ +### Fixed + +- fit:canvas event is not generated if to fit it from the controls sidebar + () diff --git a/cvat-canvas/src/typescript/canvasModel.ts b/cvat-canvas/src/typescript/canvasModel.ts index 0ad62484c14c..0225c738683b 100644 --- a/cvat-canvas/src/typescript/canvasModel.ts +++ b/cvat-canvas/src/typescript/canvasModel.ts @@ -687,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 { diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index d1bab2369521..f21255ab4213 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -1651,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(); }); @@ -1896,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) { From 194b79cc0de6b2bf532511c711f520bea6c3ccfc Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Mon, 2 Dec 2024 18:13:10 +0200 Subject: [PATCH 100/163] Fixed issue: user may navigate forward with an opened modal (#8748) --- ...d_user_may_navigate_forward_when_model_opened.md | 4 ++++ cvat-ui/src/utils/mousetrap-react.tsx | 13 +++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 changelog.d/20241127_170908_sekachev.bs_fixed_user_may_navigate_forward_when_model_opened.md diff --git a/changelog.d/20241127_170908_sekachev.bs_fixed_user_may_navigate_forward_when_model_opened.md b/changelog.d/20241127_170908_sekachev.bs_fixed_user_may_navigate_forward_when_model_opened.md new file mode 100644 index 000000000000..edcc311a5ce2 --- /dev/null +++ b/changelog.d/20241127_170908_sekachev.bs_fixed_user_may_navigate_forward_when_model_opened.md @@ -0,0 +1,4 @@ +### Fixed + +- User may navigate forward with a keyboard when a modal opened + () diff --git a/cvat-ui/src/utils/mousetrap-react.tsx b/cvat-ui/src/utils/mousetrap-react.tsx index 791760a35bdd..f7adf16930ff 100644 --- a/cvat-ui/src/utils/mousetrap-react.tsx +++ b/cvat-ui/src/utils/mousetrap-react.tsx @@ -64,24 +64,29 @@ export default function GlobalHotKeys(props: Props): JSX.Element { Mousetrap.prototype.stopCallback = function (e: KeyboardEvent, element: Element, combo: string): boolean { if (element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA') { - // stop for input, select, and textarea + // do not trigger any shortcuts if input field is one of [input, select, textarea] return true; } const activeSequences = Object.values(applicationKeyMap).map((keyMap) => [...keyMap.sequences]).flat(); if (activeSequences.some((sequence) => sequence.startsWith(combo))) { + // prevent default behaviour of the event if potentially one of active shortcuts will be trigerred e?.preventDefault(); } // stop when modals are opened - const someModalsOpened = Array.from( + const anyModalsOpened = Array.from( window.document.getElementsByClassName('ant-modal'), ).some((el) => (el as HTMLElement).style.display !== 'none'); - if (someModalsOpened) { + if (anyModalsOpened) { const modalClosingSequences = ['SWITCH_SHORTCUTS', 'SWITCH_SETTINGS'] .map((key) => [...(applicationKeyMap[key]?.sequences ?? [])]).flat(); - return !modalClosingSequences.includes(combo) && !modalClosingSequences.some((seq) => seq.startsWith(combo)); + + return !modalClosingSequences.some((seq) => { + const seqFragments = seq.split('+'); + return combo.split('+').every((key, i) => seqFragments[i] === key); + }); } return false; From fc37eb3cab07bd7ded8d22df3b42e45fb7dc5bd2 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Mon, 2 Dec 2024 18:13:26 +0200 Subject: [PATCH 101/163] Fixed issue: player navigates to removed frames when playing (#8747) --- ...45739_sekachev.bs_simplified_navigation.md | 4 + .../annotation-page/top-bar/top-bar.tsx | 104 ++++++++---------- 2 files changed, 52 insertions(+), 56 deletions(-) create mode 100644 changelog.d/20241127_145739_sekachev.bs_simplified_navigation.md diff --git a/changelog.d/20241127_145739_sekachev.bs_simplified_navigation.md b/changelog.d/20241127_145739_sekachev.bs_simplified_navigation.md new file mode 100644 index 000000000000..4fb6385e1d21 --- /dev/null +++ b/changelog.d/20241127_145739_sekachev.bs_simplified_navigation.md @@ -0,0 +1,4 @@ +### Fixed + +- Player may navigate to removed frames when playing + () diff --git a/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx b/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx index e9d2785faf10..7185106e05d8 100644 --- a/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx @@ -28,7 +28,7 @@ import { import AnnotationTopBarComponent from 'components/annotation-page/top-bar/top-bar'; import { Canvas } from 'cvat-canvas-wrapper'; import { Canvas3d } from 'cvat-canvas3d-wrapper'; -import { DimensionType, Job, JobType } from 'cvat-core-wrapper'; +import { Job } from 'cvat-core-wrapper'; import { CombinedState, FrameSpeed, @@ -225,10 +225,12 @@ type Props = StateToProps & DispatchToProps & RouteComponentProps; class AnnotationTopBarContainer extends React.PureComponent { private inputFrameRef: React.RefObject; private autoSaveInterval: number | undefined; + private isWaitingForPlayDelay: boolean; private unblock: any; constructor(props: Props) { super(props); + this.isWaitingForPlayDelay = false; this.inputFrameRef = React.createRef(); } @@ -270,7 +272,7 @@ class AnnotationTopBarContainer extends React.PureComponent { if (this.autoSaveInterval) window.clearInterval(this.autoSaveInterval); this.autoSaveInterval = window.setInterval(this.autoSave.bind(this), autoSaveInterval); } - this.play(); + this.handlePlayIfNecessary(); } public componentWillUnmount(): void { @@ -279,6 +281,50 @@ class AnnotationTopBarContainer extends React.PureComponent { this.unblock(); } + private async handlePlayIfNecessary(): Promise { + const { + jobInstance, + frameNumber, + frameDelay, + frameFetching, + playing, + canvasIsReady, + onSwitchPlay, + onChangeFrame, + } = this.props; + + const { stopFrame } = jobInstance; + + if (playing && canvasIsReady && !frameFetching && !this.isWaitingForPlayDelay) { + this.isWaitingForPlayDelay = true; + try { + await new Promise((resolve) => { + setTimeout(resolve, frameDelay); + }); + + const { playing: currentPlaying, showDeletedFrames } = this.props; + + if (currentPlaying) { + const nextCandidate = frameNumber + 1; + if (nextCandidate > stopFrame) { + onSwitchPlay(false); + return; + } + + const next = await jobInstance.frames + .search({ notDeleted: !showDeletedFrames }, nextCandidate, stopFrame); + if (next !== null && isAbleToChangeFrame(next)) { + onChangeFrame(next, currentPlaying); + } else { + onSwitchPlay(false); + } + } + } finally { + this.isWaitingForPlayDelay = false; + } + } + } + private undo = (): void => { const { undo, undoAction } = this.props; @@ -569,60 +615,6 @@ class AnnotationTopBarContainer extends React.PureComponent { return undefined; }; - private play(): void { - const { - jobInstance, - frameSpeed, - frameNumber, - frameDelay, - frameFetching, - playing, - canvasIsReady, - onSwitchPlay, - onChangeFrame, - } = this.props; - - if (playing && canvasIsReady && !frameFetching) { - if (frameNumber < jobInstance.stopFrame) { - let framesSkipped = 0; - if (frameSpeed === FrameSpeed.Fast && frameNumber + 1 < jobInstance.stopFrame) { - framesSkipped = 1; - } - if (frameSpeed === FrameSpeed.Fastest && frameNumber + 2 < jobInstance.stopFrame) { - framesSkipped = 2; - } - - setTimeout(async () => { - const { playing: stillPlaying } = this.props; - if (stillPlaying) { - if (isAbleToChangeFrame()) { - if (jobInstance.type === JobType.GROUND_TRUTH) { - const newFrame = await jobInstance.frames.search( - { notDeleted: true }, - frameNumber + 1, - jobInstance.stopFrame, - ); - if (newFrame !== null) { - onChangeFrame(newFrame, stillPlaying); - } else { - onSwitchPlay(false); - } - } else { - onChangeFrame(frameNumber + 1 + framesSkipped, stillPlaying, framesSkipped + 1); - } - } else if (jobInstance.dimension === DimensionType.DIMENSION_2D) { - onSwitchPlay(false); - } else { - setTimeout(() => this.play(), frameDelay); - } - } - }, frameDelay); - } else { - onSwitchPlay(false); - } - } - } - private autoSave(): void { const { autoSave, saving, onSaveAnnotation } = this.props; From 17ec90862ade0a7e257c48d940222b6fc643915e Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 3 Dec 2024 12:01:30 +0300 Subject: [PATCH 102/163] Fix frame names in the allocation table for simple GT job (#8731) ### Motivation and context - Fixed invalid display of frame names in quality management for tasks with a simple GT job ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Enhanced the `AllocationTable` component to display frame names based on the selected validation mode. - **Bug Fixes** - Improved accuracy of frame name retrieval in the allocation table, ensuring correct display under different validation conditions. --- .../20241121_181517_mzhiltso_fix_allocation_table.md | 4 ++++ .../quality-control/task-quality/allocation-table.tsx | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20241121_181517_mzhiltso_fix_allocation_table.md diff --git a/changelog.d/20241121_181517_mzhiltso_fix_allocation_table.md b/changelog.d/20241121_181517_mzhiltso_fix_allocation_table.md new file mode 100644 index 000000000000..a1af44276acc --- /dev/null +++ b/changelog.d/20241121_181517_mzhiltso_fix_allocation_table.md @@ -0,0 +1,4 @@ +### Fixed + +- Incorrect display of validation frames on the task quality management page + () diff --git a/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx b/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx index 914909bc90c1..d1e366f71da1 100644 --- a/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx +++ b/cvat-ui/src/components/quality-control/task-quality/allocation-table.tsx @@ -19,6 +19,7 @@ import { } from 'cvat-core-wrapper'; import CVATTooltip from 'components/common/cvat-tooltip'; import { sorter } from 'utils/quality'; +import { ValidationMode } from 'components/create-task-page/quality-configuration-form'; interface Props { task: Task; @@ -52,7 +53,11 @@ function AllocationTable(props: Readonly): JSX.Element { const data = validationLayout.validationFrames.map((frame: number, index: number) => ({ key: frame, frame, - name: gtJobMeta.frames[index]?.name ?? gtJobMeta.frames[0].name, + name: gtJobMeta.frames[ + // - gt job meta starts from the 0 task frame; + // - honeypot gt job meta starts from the job start frame; + (validationLayout.mode === ValidationMode.GT) ? frame : index + ]?.name ?? gtJobMeta.frames[0].name, active: !disabledFrames.includes(frame), })); From 7d9c3681531b044c61a7d10a3a6dd39163de678b Mon Sep 17 00:00:00 2001 From: Dmitrii Lavrukhin Date: Wed, 4 Dec 2024 11:43:39 +0300 Subject: [PATCH 103/163] not prefetching images when not needed (#8676) ### Motivation and context While importing annotations to task, all jobs of the task are loaded from db to ram. Related data is prefetched, specifically all image models which belong to the task. As a result, each job holds its own copy of all the image models. If there are a lot of jobs and a lot of images in the task, a lot of memory can be occupied. And images are not utilised on annotations import/delete. Hence - do not prefetch images in these cases. ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Enhanced job retrieval process with improved error handling. - Introduced a mechanism for custom querysets in job initialization. - **Bug Fixes** - Improved robustness in job fetching to prevent failures when jobs are not found. - **Refactor** - Updated logic in the `JobAnnotation` class for clearer control flow and initialization. --------- Co-authored-by: Maria Khrustaleva Co-authored-by: Maxim Zhiltsov --- ...658_dmitrii.lavrukhin_no_queryset_cache.md | 5 + cvat/apps/dataset_manager/bindings.py | 6 +- cvat/apps/dataset_manager/task.py | 91 ++++++++++++------- 3 files changed, 67 insertions(+), 35 deletions(-) create mode 100644 changelog.d/20241113_130658_dmitrii.lavrukhin_no_queryset_cache.md diff --git a/changelog.d/20241113_130658_dmitrii.lavrukhin_no_queryset_cache.md b/changelog.d/20241113_130658_dmitrii.lavrukhin_no_queryset_cache.md new file mode 100644 index 000000000000..8efcd99d7bf8 --- /dev/null +++ b/changelog.d/20241113_130658_dmitrii.lavrukhin_no_queryset_cache.md @@ -0,0 +1,5 @@ +### Fixed + +- Optimized memory consumption and reduced the number of database queries + when importing annotations to a task with a lot of jobs and images + () diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 9b01dced2a94..3b2ccd782a88 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -22,7 +22,7 @@ import rq from attr import attrib, attrs from datumaro.components.format_detection import RejectionReason -from django.db.models import QuerySet +from django.db.models import Prefetch, QuerySet from django.utils import timezone from django.conf import settings @@ -859,7 +859,9 @@ def __init__(self, annotation_ir: AnnotationIR, db_task: Task, **kwargs): @staticmethod def meta_for_task(db_task, host, label_mapping=None): - db_segments = db_task.segment_set.all().prefetch_related('job_set') + db_segments = db_task.segment_set.all().prefetch_related( + Prefetch('job_set', models.Job.objects.order_by("pk")) + ) meta = OrderedDict([ ("id", str(db_task.id)), diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 5b72f92a1ebc..45f1eaff4e8e 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -13,7 +13,7 @@ from datumaro.components.errors import DatasetError, DatasetImportError, DatasetNotFoundError from django.db import transaction -from django.db.models.query import Prefetch +from django.db.models.query import Prefetch, QuerySet from django.conf import settings from rest_framework.exceptions import ValidationError @@ -81,9 +81,10 @@ def merge_table_rows(rows, keys_for_merge, field_id): return list(merged_rows.values()) + class JobAnnotation: @classmethod - def add_prefetch_info(cls, queryset): + def add_prefetch_info(cls, queryset: QuerySet, prefetch_images: bool = True): assert issubclass(queryset.model, models.Job) label_qs = add_prefetch_fields(models.Label.objects.all(), [ @@ -93,6 +94,12 @@ def add_prefetch_info(cls, queryset): ]) label_qs = JobData.add_prefetch_info(label_qs) + task_data_queryset = models.Data.objects.all() + if prefetch_images: + task_data_queryset = task_data_queryset.select_related('video').prefetch_related( + Prefetch('images', queryset=models.Image.objects.order_by('frame')) + ) + return queryset.select_related( 'segment', 'segment__task', @@ -100,28 +107,35 @@ def add_prefetch_info(cls, queryset): 'segment__task__project', 'segment__task__owner', 'segment__task__assignee', - 'segment__task__project__owner', - 'segment__task__project__assignee', - Prefetch('segment__task__data', - queryset=models.Data.objects.select_related('video').prefetch_related( - Prefetch('images', queryset=models.Image.objects.order_by('frame')) - )), + Prefetch('segment__task__data', queryset=task_data_queryset), Prefetch('segment__task__label_set', queryset=label_qs), Prefetch('segment__task__project__label_set', queryset=label_qs), ) - def __init__(self, pk, *, is_prefetched=False, queryset=None): - if queryset is None: - queryset = self.add_prefetch_info(models.Job.objects) + def __init__( + self, + pk, + *, + lock_job_in_db: bool = False, + queryset: QuerySet | None = None, + prefetch_images: bool = False, + db_job: models.Job | None = None + ): + assert db_job is None or lock_job_in_db is False + assert (db_job is None and queryset is None) or prefetch_images is False + assert db_job is None or queryset is None + if db_job is None: + if queryset is None: + queryset = self.add_prefetch_info(models.Job.objects, prefetch_images=prefetch_images) + + if lock_job_in_db: + queryset = queryset.select_for_update() - if is_prefetched: - self.db_job: models.Job = queryset.select_related( - 'segment__task' - ).select_for_update().get(id=pk) - else: self.db_job: models.Job = get_cached(queryset, pk=int(pk)) + else: + self.db_job: models.Job = db_job db_segment = self.db_job.segment self.start_frame = db_segment.start_frame @@ -786,6 +800,7 @@ def import_annotations(self, src_file, importer, **options): self.create(job_data.data.slice(self.start_frame, self.stop_frame).serialize()) + class TaskAnnotation: def __init__(self, pk): self.db_task = models.Task.objects.prefetch_related( @@ -797,8 +812,7 @@ def __init__(self, pk): requested_job_types.append(models.JobType.GROUND_TRUTH) self.db_jobs = ( - models.Job.objects - .select_related("segment") + JobAnnotation.add_prefetch_info(models.Job.objects, prefetch_images=False) .filter(segment__task_id=pk, type__in=requested_job_types) ) @@ -821,14 +835,14 @@ def _patch_data(self, data: Union[AnnotationIR, dict], action: Optional[PatchAct start = db_job.segment.start_frame stop = db_job.segment.stop_frame jobs[jid] = { "start": start, "stop": stop } - splitted_data[jid] = data.slice(start, stop) + splitted_data[jid] = (data.slice(start, stop), db_job) - for jid, job_data in splitted_data.items(): + for jid, (job_data, db_job) in splitted_data.items(): data = AnnotationIR(self.db_task.dimension) if action is None: - data.data = put_job_data(jid, job_data) + data.data = put_job_data(jid, job_data, db_job=db_job) else: - data.data = patch_job_data(jid, job_data, action) + data.data = patch_job_data(jid, job_data, action, db_job=db_job) if data.version > self.ir_data.version: self.ir_data.version = data.version @@ -936,18 +950,18 @@ def delete(self, data=None): self._patch_data(data, PatchAction.DELETE) else: for db_job in self.db_jobs: - delete_job_data(db_job.id) + delete_job_data(db_job.id, db_job=db_job) def init_from_db(self): self.reset() - for db_job in self.db_jobs: + for db_job in self.db_jobs.select_for_update(): if db_job.type == models.JobType.GROUND_TRUTH and not ( self.db_task.data.validation_mode == models.ValidationMode.GT_POOL ): continue - gt_annotation = JobAnnotation(db_job.id, is_prefetched=True) + gt_annotation = JobAnnotation(db_job.id, db_job=db_job) gt_annotation.init_from_db() if gt_annotation.ir_data.version > self.ir_data.version: self.ir_data.version = gt_annotation.ir_data.version @@ -1006,19 +1020,21 @@ def get_job_data(pk): return annotation.data + @silk_profile(name="POST job data") @transaction.atomic -def put_job_data(pk, data): - annotation = JobAnnotation(pk) +def put_job_data(pk, data: AnnotationIR | dict, *, db_job: models.Job | None = None): + annotation = JobAnnotation(pk, db_job=db_job) annotation.put(data) return annotation.data + @silk_profile(name="UPDATE job data") @plugin_decorator @transaction.atomic -def patch_job_data(pk, data, action): - annotation = JobAnnotation(pk) +def patch_job_data(pk, data: AnnotationIR | dict, action: PatchAction, *, db_job: models.Job | None = None): + annotation = JobAnnotation(pk, db_job=db_job) if action == PatchAction.CREATE: annotation.create(data) elif action == PatchAction.UPDATE: @@ -1028,12 +1044,14 @@ def patch_job_data(pk, data, action): return annotation.data + @silk_profile(name="DELETE job data") @transaction.atomic -def delete_job_data(pk): - annotation = JobAnnotation(pk) +def delete_job_data(pk, *, db_job: models.Job | None = None): + annotation = JobAnnotation(pk, db_job=db_job) annotation.delete() + def export_job(job_id, dst_file, format_name, server_url=None, save_images=False): # For big tasks dump function may run for a long time and # we dont need to acquire lock after the task has been initialized from DB. @@ -1041,13 +1059,14 @@ def export_job(job_id, dst_file, format_name, server_url=None, save_images=False # more dump request received at the same time: # https://github.com/cvat-ai/cvat/issues/217 with transaction.atomic(): - job = JobAnnotation(job_id) + job = JobAnnotation(job_id, prefetch_images=True, lock_job_in_db=True) job.init_from_db() exporter = make_exporter(format_name) with open(dst_file, 'wb') as f: job.export(f, exporter, host=server_url, save_images=save_images) + @silk_profile(name="GET task data") @transaction.atomic def get_task_data(pk): @@ -1056,6 +1075,7 @@ def get_task_data(pk): return annotation.data + @silk_profile(name="POST task data") @transaction.atomic def put_task_data(pk, data): @@ -1064,6 +1084,7 @@ def put_task_data(pk, data): return annotation.data + @silk_profile(name="UPDATE task data") @transaction.atomic def patch_task_data(pk, data, action): @@ -1077,12 +1098,14 @@ def patch_task_data(pk, data, action): return annotation.data + @silk_profile(name="DELETE task data") @transaction.atomic def delete_task_data(pk): annotation = TaskAnnotation(pk) annotation.delete() + def export_task(task_id, dst_file, format_name, server_url=None, save_images=False): # For big tasks dump function may run for a long time and # we dont need to acquire lock after the task has been initialized from DB. @@ -1097,6 +1120,7 @@ def export_task(task_id, dst_file, format_name, server_url=None, save_images=Fal with open(dst_file, 'wb') as f: task.export(f, exporter, host=server_url, save_images=save_images) + @transaction.atomic def import_task_annotations(src_file, task_id, format_name, conv_mask_to_poly): task = TaskAnnotation(task_id) @@ -1108,9 +1132,10 @@ def import_task_annotations(src_file, task_id, format_name, conv_mask_to_poly): except (DatasetError, DatasetImportError, DatasetNotFoundError) as ex: raise CvatImportError(str(ex)) + @transaction.atomic def import_job_annotations(src_file, job_id, format_name, conv_mask_to_poly): - job = JobAnnotation(job_id) + job = JobAnnotation(job_id, prefetch_images=True) importer = make_importer(format_name) with open(src_file, 'rb') as f: From 022f45d76522be28dfef3e91c2530bba57bfcb53 Mon Sep 17 00:00:00 2001 From: Kirill Lakhov Date: Wed, 4 Dec 2024 14:04:25 +0300 Subject: [PATCH 104/163] Update assets for chunk test (#8740) ### Motivation and context - Added a setting into cvat-core to manipulate `jobMetaDataReloadPeriod` ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - ~~[ ] I have created a changelog fragment ~~ - ~~[ ] I have updated the documentation accordingly~~ - ~~[ ] I have added tests to cover my changes~~ - ~~[ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword))~~ - ~~[ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning))~~ ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced a new configuration option for managing the job metadata reload period, allowing users to set it dynamically. - The default reload period is set to one hour. - **Improvements** - Enhanced the logic for checking frame metadata freshness by using the new dynamic reload period instead of a fixed constant. --------- Co-authored-by: Boris Sekachev --- cvat-core/src/api.ts | 6 ++++++ cvat-core/src/config.ts | 2 ++ cvat-core/src/frames.ts | 4 ++-- cvat-core/src/index.ts | 1 + tests/cypress/support/commands.js | 8 ++++++++ 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index f4eb5d8b23fd..60de43fd4b18 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -320,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}`, 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/frames.ts b/cvat-core/src/frames.ts index e02b3e640a83..dfdd35684178 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 { 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 diff --git a/cvat-core/src/index.ts b/cvat-core/src/index.ts index 79ce8b305a9f..4eff35601f70 100644 --- a/cvat-core/src/index.ts +++ b/cvat-core/src/index.ts @@ -187,6 +187,7 @@ export default interface CVATCore { onOrganizationChange: (newOrgId: number | null) => void | null; globalObjectsCounter: typeof config.globalObjectsCounter; requestsStatusDelay: typeof config.requestsStatusDelay; + jobMetaDataReloadPeriod: typeof config.jobMetaDataReloadPeriod; }, client: { version: string; diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index f0f085260cbf..9941a9b0d5c3 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -908,6 +908,14 @@ Cypress.Commands.add('configureTaskQualityMode', (qualityConfigurationParams) => cy.contains(qualityConfigurationParams.validationMode).click(); }); } + if (qualityConfigurationParams.validationFramesPercent) { + cy.get('#validationFramesPercent').clear(); + cy.get('#validationFramesPercent').type(qualityConfigurationParams.validationFramesPercent); + } + if (qualityConfigurationParams.validationFramesPerJobPercent) { + cy.get('#validationFramesPerJobPercent').clear(); + cy.get('#validationFramesPerJobPercent').type(qualityConfigurationParams.validationFramesPerJobPercent); + } }); Cypress.Commands.add('removeAnnotations', () => { From f098ee520500537b7c65f2b2ba338ad6ce2f932b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 4 Dec 2024 18:18:25 +0300 Subject: [PATCH 105/163] Add quality modes and management docs (#8732) ### Motivation and context - Added information about dataset quality assurance featured added recently - Refactored the Immediate Feedback page ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced a new document on quality control features, detailing validation sets, job validation, and quality analytics. - Updated the "Automated QA" document with new features and clearer guidance on quality assurance processes. - **Documentation** - Enhanced clarity and consistency across multiple documents, including updates to punctuation and formatting. - Restructured content for better understanding of automated quality control and immediate feedback mechanisms. --------- Co-authored-by: Maria Khrustaleva --- .../en/docs/enterprise/immediate-feedback.md | 81 ++- .../analytics-and-monitoring/auto-qa.md | 619 ++++++++++++++---- .../en/docs/manual/basics/quality-control.md | 109 +++ site/content/en/images/honeypot09.jpg | Bin 0 -> 31966 bytes site/content/en/images/honeypot10.png | Bin 0 -> 39059 bytes site/content/en/images/honeypot11.jpg | Bin 0 -> 19341 bytes site/content/en/images/honeypot12.jpg | Bin 0 -> 22296 bytes .../immediate-feedback-quality-settings.png | Bin 114770 -> 44505 bytes .../en/images/quality_comparison_bbox1.svg | 80 +++ .../images/quality_comparison_polylines1.png | Bin 0 -> 9496 bytes .../images/quality_comparison_skeleton1.svg | 275 ++++++++ .../en/images/quality_download_report.png | Bin 0 -> 4984 bytes 12 files changed, 1010 insertions(+), 154 deletions(-) create mode 100644 site/content/en/docs/manual/basics/quality-control.md create mode 100644 site/content/en/images/honeypot09.jpg create mode 100644 site/content/en/images/honeypot10.png create mode 100644 site/content/en/images/honeypot11.jpg create mode 100644 site/content/en/images/honeypot12.jpg create mode 100644 site/content/en/images/quality_comparison_bbox1.svg create mode 100644 site/content/en/images/quality_comparison_polylines1.png create mode 100644 site/content/en/images/quality_comparison_skeleton1.svg create mode 100644 site/content/en/images/quality_download_report.png diff --git a/site/content/en/docs/enterprise/immediate-feedback.md b/site/content/en/docs/enterprise/immediate-feedback.md index 76bde8df99e5..e336e7815e21 100644 --- a/site/content/en/docs/enterprise/immediate-feedback.md +++ b/site/content/en/docs/enterprise/immediate-feedback.md @@ -2,49 +2,80 @@ title: 'Immediate job feedback' linkTitle: 'Immediate job feedback' weight: 5 -description: 'This feature provides annotators with general feedback on their performance in a job.' +description: 'Quick responses about job annotation quality' --- -When an annotator finishes a job, a dialog is displayed showing the quality of their annotations. -The annotator can either agree or disagree with the feedback. +## Overview + +The basic idea behind this feature is to provide annotators with quick feedback on their +performance in a job. When an annotator finishes a job, a dialog is displayed showing the +quality of their annotations. The annotator can either agree or disagree with the feedback. If they disagree, they have the option to re-annotate the job and request feedback again. -However, feedback is only available a limited number of times, as specified in the task's quality settings. -To ensure transparency with the annotator, the immediate feedback shows the collected score and -the minimum required score. -Immediate feedback settings, such as `Target metric`, `Target metric threshold`, -`Max validations per job` and others, can be configured on the quality settings page: - +To ensure transparency with the annotator, the immediate feedback shows the computed score and +the minimum required score. Information about the specific errors or frames that have errors is +not available to annotators. + +Feedback is only available a limited number of times for each assignment, to prevent +Ground Truth revealing by annotators. This is controlled by a configurable parameter, so +it can be adjusted to the requirements of each project. + +## How to configure + +Immediate feedback settings, such as `Target metric`, `Target metric threshold`, +`Max validations per job` and others, can be configured on the quality settings page. + +This feature is considered enabled if the `Max validations per job` is above 0. You can change +the parameters any time. + +> **Note**: This feature requires a configured validation set in the task. Read more +> in the +> {{< ilink "/docs/manual/basics/quality-control#how-to-enable-quality-control" "quality overview" >}} +> section or in the +{{< ilink "/docs/manual/advanced/analytics-and-monitoring/auto-qa#configuring-quality-estimation" "full guide" >}}. + +1. Open the task **Actions** menu > **Quality control** > **Settings** + + ![Configure job validations](/images/immediate-feedback-quality-settings.png) + +2. Set the `Target metric` and `Target metric threshold` values to what is required in your project. +3. Set **Max validations per job** to above zero. 3 is a good starting number. +4. Save the updated settings - +## How to receive a feedback + +1. Assign an annotator to an annotation job +2. Annotate the job +3. Mark the job finished using the corresponding button in the menu +4. Once the job is completed, you'll see the job validation dialog + + + +Each assignee gets no more than the specified number of validation attempts. + +> **Note**: this functionality is only available in regular annotation jobs. For instance, +> it's not possible to use it in Ground Truth jobs. ### Available feedbacks There are three types of feedbacks available for different cases: - Accepted -- Rejected, but can be adjusted +- Rejected, with an option to fix mistakes - Finally rejected when the number of attempts is exhausted -Notes: +## Additional details > Immediate feedback has a default timeout of 20 seconds. -Feedback may be unavailable for large jobs or when there are too many immediate feedback requests. -In this case annotators do not see any feedback dialogs. +> Feedback may be unavailable for large jobs or when there are too many immediate feedback requests. +> In this case annotators do not see any feedback dialogs and annotate jobs as +> if the feature was disabled. -> The number of attempts does not decrease for staff members who have access to a job with ground truth annotations. +> The number of attempts does not decrease for staff members who have access to a job +> with ground truth annotations. For instance, if you're trying to test this feature as the task +> owner, you may be confused if you see the number of attempts doesn't decrease. > The number of attempts resets when the job assignee is updated. - -Requirements: -1. The task is configured with a Ground Truth job that has been annotated, -moved to the acceptance stage, and is in the completed state. -2. The current job is in the annotation stage. -3. The current job is a regular annotation job. Immediate feedback is not available for Ground Truth jobs -4. The `Max validations per job` setting has been configured on the quality settings page. - - - diff --git a/site/content/en/docs/manual/advanced/analytics-and-monitoring/auto-qa.md b/site/content/en/docs/manual/advanced/analytics-and-monitoring/auto-qa.md index e2642f4d59ee..19166c79aea5 100644 --- a/site/content/en/docs/manual/advanced/analytics-and-monitoring/auto-qa.md +++ b/site/content/en/docs/manual/advanced/analytics-and-monitoring/auto-qa.md @@ -1,167 +1,351 @@ --- -title: 'Automated QA, Review & Honeypot' +title: 'Automated QA, Review & Honeypots' linkTitle: 'Automated QA' weight: 1 description: 'Guidelines for assessing annotation quality in CVAT automatically' --- -In CVAT, it's possible to evaluate the quality of annotation through -the creation of a **Ground truth** job, referred to as a Honeypot. -To estimate the task quality, CVAT compares all other jobs in the task against the -established **Ground truth** job, and calculates annotation quality -based on this comparison. +In CVAT, it's possible to evaluate the quality of annotation through the creation +of a validation subset of images. To estimate the task quality, CVAT compares +all other jobs in the task against the established **Ground truth** job, +and calculates annotation quality based on this comparison. > **Note** that quality estimation only supports > 2d tasks. It supports all the annotation types except 2d cuboids. -> **Note** that tracks are considered separate shapes -> and compared on a per-frame basis with other tracks and shapes. - -See: - -- [Ground truth job](#ground-truth-job) -- [Managing Ground Truth jobs: Import, Export, and Deletion](#managing-ground-truth-jobs-import-export-and-deletion) - - [Import](#import) - - [Export](#export) - - [Delete](#delete) -- [Assessing data quality with Ground truth jobs](#assessing-data-quality-with-ground-truth-jobs) - - [Quality data](#quality-data) - - [Annotation quality settings](#annotation-quality-settings) - - [GT conflicts in the CVAT interface](#gt-conflicts-in-the-cvat-interface) -- [Annotation quality \& Honeypot video tutorial](#annotation-quality--honeypot-video-tutorial) - -## Ground truth job - -A **Ground truth** job is a way to tell CVAT where to store -and get the "correct" annotations for task quality estimation. - -To estimate task quality, you need to -create a **Ground truth** job in the task, -and annotate it. You don’t need to -annotate the whole dataset twice, -the annotation quality of a small part of -the data shows the quality of annotation for -the whole dataset. - -For the quality assurance to function correctly, the **Ground truth** job must -have a small portion of the task frames and the frames must be chosen randomly. -Depending on the dataset size and task complexity, +> **Note** that quality estimation is currently available for tasks and jobs. +> Quality estimation in projects is not supported. + +CVAT has the following features for automated quality control of annotations: +- Validation set configuration for a task +- Job validation on job finish ("{{< ilink "/docs/enterprise/immediate-feedback" "Immediate feedback" >}}") +- Review mode for problems found +- Quality analytics + +## Basics + +There are several approaches to quality estimation used in the industry. In CVAT, +we can use a method known as Ground Truth or Honeypots. The method assumes there are +Ground Truth annotations for images in the dataset. This method is statistical, +which means that we can use only a small portion of the whole dataset to +estimate quality on the full dataset, so we don't need to annotate the whole dataset twice. +Here we assume that the images in the dataset are similar (represent the same task). + +We will call the validation portion of the whole dataset (or a task in CVAT) a validation set. +In practice, it is typically expected that annotations in the validation set are carefully +validated and curated. It means that they are more expensive - creating them might require +expert annotators or just several iterations of annotation and validation. It means that it's +desirable to keep the validation set small enough. At the same time, it must be representative +enough to provide reliable estimations. To achieve this, it's advised that the validation set +images are sampled randomly and independently from the full dataset. +That is, for the quality assurance to function correctly, the validation set must +have some portion of the task frames, and the frames must be chosen randomly. + +Depending on the dataset size, data variance, and task complexity, **5-15% of the data is typically good enough** for quality estimation, -while keeping extra annotation overhead acceptable. +while keeping extra annotation overhead for the Ground Truth acceptable. For example, in a typical **task with 2000 frames**, selecting **just 5%**, which is 100 extra frames to annotate, **is enough** to estimate the annotation quality. If the task contains **only 30 frames**, it's advisable to -select **8-10 frames**, which is **about 30%**. +select **8-10 frames**, which is **about 30%**. It is more than 15%, +but in the case of smaller datasets, we need more samples to estimate quality reliably, +as data variance is higher. -It is more than 15% but in the case of smaller datasets, -we need more samples to estimate quality reliably. +## Ground truth jobs -To create a **Ground truth** job, do the following: +A **Ground Truth job** (GT job) is a way to represent the validation set in a CVAT task. +This job is similar to regular annotation jobs - you can edit the annotations manually, +use auto-annotation features, and import annotations in this job. There can be no more +than 1 Ground Truth job in a task. -1. Create a {{< ilink "/docs/manual/basics/create_an_annotation_task" "task" >}}, and open the task page. +To enable quality estimation in a task, you need to create a Ground truth job in the task, +annotate it, switch the job stage to `acceptance`, and set the job state to `completed`. +Once the Ground Truth job is configured, CVAT will start using this job for quality estimation. + +Read more about Ground Truth management [here](#ground-truth-job-management). + +## Configuring quality estimation + +Quality estimation is configured on the Task level. + +{{< tabpane text=true >}} + +{{%tab header="In a new task" %}} +1. Go to the {{< ilink "/docs/manual/basics/create_an_annotation_task" "task creation" >}} page +2. Configure basic and advanced parameters according to your requirements, and attach a dataset to be annotated +3. Scroll down to the **Quality Control** section below +4. Select one of the [validation modes](#validation-modes) available + + ![Create task with validation mode](/images/honeypot09.jpg) + +5. Create the task and open the task page +6. Upload or create Ground Truth annotations in the Ground Truth job in the task +7. Switch the Ground Truth job into the `acceptance` stage and `completed` state + + ![Set job status](/images/honeypot10.jpg) +{{% /tab %}} + +{{%tab header="In an existing task" %}} +> For already existing tasks only the Ground Truth validation mode is available. If you want +> to use Honeypots for your task, you will need to recreate the task. + +1. Open the task page 2. Click **+**. - ![Create job](/images/honeypot01.jpg) + ![Create job](/images/honeypot01.jpg) 3. In the **Add new job** window, fill in the following fields: - ![Add new job](/images/honeypot02.jpg) + ![Configure job parameters](/images/honeypot02.jpg) - - **Job type**: Use the default parameter **Ground truth**. - - **Frame selection method**: Use the default parameter **Random**. - - **Quantity %**: Set the desired percentage of frames for the **Ground truth** job. -
**Note** that when you use **Quantity %**, the **Frames** field will be autofilled. - - **Frame count**: Set the desired number of frames for the "ground truth" job. -
**Note** that when you use **Frames**, the **Quantity %** field will be will be autofilled. - - **Seed**: (Optional) If you need to make the random selection reproducible, specify this number. - It can be any integer number, the same value will yield the same random selection (given that the - frame number is unchanged).
**Note** that if you want to use a - custom frame sequence, you can do this using the server API instead, - see [Jobs API #create](https://docs.cvat.ai/docs/api_sdk/sdk/reference/apis/jobs-api/#create). +- **Job type**: Use the default parameter **Ground truth**. +- **Frame selection method**: Use the default parameter **Random**. +- **Quantity %**: Set the desired percentage of frames for the Ground truth job. +
**Note** that when you use **Quantity %**, the **Frames** field will be autofilled. +- **Frame count**: Set the desired number of frames for the Ground truth job. +
**Note** that when you use **Frames**, the **Quantity %** field will be autofilled. +- **Seed**: (Optional) If you need to make the random selection reproducible, specify this number. + It can be any integer number, the same value will yield the same random selection (given that the + frame number is unchanged).
**Note** that if you want to use a + custom frame sequence, you can do this using the server API instead, + see [Job API create()](https://docs.cvat.ai/docs/api_sdk/sdk/reference/apis/jobs-api/#create). 4. Click **Submit**. -5. Annotate frames, save your work. -6. Change the status of the job to **Completed**. -7. Change **Stage** to **Accepted**. The **Ground truth** job will appear in the jobs list. -![Add new job](/images/honeypot03.jpg) + ![Ground Truth job](/images/honeypot03.jpg) -## Managing Ground Truth jobs: Import, Export, and Deletion +5. Annotate frames and save your work or upload annotations. +6. Switch the Ground Truth job into the `acceptance` stage and `completed` state -Annotations from **Ground truth** jobs are not included in the dataset export, -they also cannot be imported during task annotations import -or with automatic annotation for the task. + ![Set job status](/images/honeypot10.jpg) +{{% /tab %}} -Import, export, and delete options are available from the -job's menu. +{{< /tabpane >}} -![Add new job](/images/honeypot04.jpg) +> A **Ground truth** job is considered **configured** +> if it is at the **acceptance** stage and in the **completed** state. -### Import +A _configured_ Ground Truth job is required for all quality computations in CVAT. -If you want to import annotations into the **Ground truth** job, do the following. +## Validation modes -1. Open the task, and find the **Ground truth** job in the jobs list. -2. Click on three dots to open the menu. -3. From the menu, select **Import annotations**. -4. Select import format, and select file. -5. Click **OK**. +Currently, there are 2 validation modes available for tasks: **Ground Truth** and **Honeypots**. +These names are often used interchangeably, but in CVAT they have some differences. +Both modes rely on the use of Ground Truth annotations in a task, +stored in a [Ground Truth job](#ground-truth-jobs), where they can be managed. -> **Note** that if there are imported annotations for the frames that exist in the task, -> but are not included in the **Ground truth** job, they will be ignored. -> This way, you don't need to worry about "cleaning up" your **Ground truth** -> annotations for the whole dataset before importing them. -> Importing annotations for the frames that are not known in the task still raises errors. +### Ground Truth -### Export +In this mode some of the task frames are selected into the validation set, represented as a +separate Ground Truth job. The regular annotation jobs in the task are not affected in any way. -To export annotations from the **Ground truth** job, do the following. +Ground Truth jobs can be created at the task creation automatically or +manually at any moment later. They can also be removed manually at any moment. +This validation mode is available for any tasks and annotations. -1. Open the task, and find a job in the jobs list. -2. Click on three dots to open the menu. -3. From the menu, select **Export annotations**. +This is a flexible mode that can be enabled or disabled at any moment without any disruptions +to the annotation process. + +#### Frame selection + +This validation mode can use several frame selection methods. + +##### Random + +This is a simple method that selects frames into the validation set randomly, +representing the [basic approach](#basics), described above. + +Parameters: +- frame count - the number or percent of the task frames to be used for validation. + Can be specified as an absolute number in the `Frame count` field or a percent in the `Quantity` + field. If there are both fields on the page, they are linked, which means changing one of them + will adjust the other one automatically. +- random seed - a number to be used to initialize the random number generator. Can be useful if + you want to create a reproducible sequence of frames. + +##### Random per job + +This method selects frames into the validation set randomly from each annotation job in the task. + +It solves one of the issues with the simple Random method that some of the jobs can get +no validation frames, which makes it impossible to estimate quality in such jobs. Note +that using this method can result in increased total size of the validation set. + +Parameters: +- frame count per job - the percent of the job frames to be used for validation. + This method uses segment size of the task to select the same number of validation frames + in each job, if possible. Can be specified as an absolute number in the `Frame count` + field or a percent in the `Quantity per job` field. If there are both fields on the page, + they are linked, which means changing one of them will adjust the other one automatically. +- random seed - a number to be used to initialize the random number generator. Can be useful if + you want to create a reproducible sequence of frames. + +### Honeypots + +In this mode some random frames of the task are selected into the validation set. +Then, validation frames are randomly mixed into regular annotation jobs. +This mode can also be called "Ground Truth pool", reflecting the way validation frames are used. +This mode can only be used at task creation and cannot be changed later. + +The mode has some limitations on the compatible tasks: +- It's not possible to use it for an already existing task, the task has to be recreated. +- This mode assumes random frame ordering, so it is only available for image annotation tasks + and not for ordered sequences like videos. +- Tracks are not supported in such tasks. + +The validation set can be managed after the task is created - annotations can be edited, +frames can be excluded and restored, and honeypot frames in the regular jobs can be changed. +However, it's not possible to select new validation frames after the task is created. +The Ground truth job created for this validation mode cannot be deleted. + +Parameters: +- frame count per job (%) - the percent of job frames (segment size) to be **added** into each + annotation job from the validation set. Can be specified in the `Overhead per job` field. +- total frame count (%) - the percent of the task frames to be included into the validation set. + This value must result in at least `frame count per job` * `segment size` frames. Can be specified + in the `Total honeypots` field. + +### Mode summary + +Here is a brief comparison of the validation modes: + +| **Aspect** | **Ground Truth** | **Honeypots** | +| -------------- | -------------------------------------------- | ------------------------------------------- | +| When can be used | any time | at task creation only | +| Frame management options | exclude, restore | exclude, restore, change honeypots in jobs | +| Ground Truth job management options | create, delete | create | +| Task frame requirements | - | random ordering only | +| Annotations | any | tracks are not supported | +| Minimum validation frames count | - `manual` and `random_uniform` - any
 (but some jobs can get no validation frames)
- `random_per_job` - jobs count * GT frames per job | not less than honeypots count per job | +| Task annotation import | GT annotations and regular annotations do not affect each other | Annotations are imported both into the GT job and regular jobs. Annotations for validation frames are copied into corresponding honeypot frames. | +| Task annotation export | GT annotations and regular annotations do not affect each other | Annotations for non-validation frames are exported as is. Annotations for validation frames are taken from the GT frames. Honeypot frames are skipped. | + +### Choosing the right mode + +Here are some examples on how to choose between these options. The general advice is to use +Ground Truth for better flexibility, but keep in mind that it can require more resources for +validation set annotation. Honeypots, on the other hand, can be beneficial if you want to +minimize the number of validation images required, but the downside here is that there are some +limitations on where this mode can be used. + +Example: a video annotation with tracks. In this case there is only 1 option - +the Ground Truth mode, so just use it. + +Example: an image dataset annotation, image order is not important. Here you can use both options. +You can choose Ground Truth for better flexibility in validation. This way, you will have the +full control of validation frames in the task, annotation options won't be limited, and the +regular jobs will not be affected in any way. However, if you have a limited budget +for the validation (for instance, you have only a small number of validation frames) or you want +to allow more scalability (with this approach the number of validation frames doesn't depend on +the number of regular annotation jobs), it makes sense to consider using Honeypots instead. + +## Quality management + +If a task has a validation configured, there are several options to manage validation set images. +With any of the validation modes, there will be a special Ground Truth (GT) job in the task. + +### Validation set management + +Validation frames can be managed on the task Quality Management page. Here it's possible to +check the number of validation frames, current validation mode and review the frame details. +For each frame you can see the number of uses in the task. When in the Ground Truth mode, this +number will be 1 for all frames. With Honeypots, these numbers can be 0, 1 or more. + +#### Frame changes + +In both validation modes it's possible to exclude some of the validation frames +from being used for validation. This can be useful if you find that some +of the validation frames are "bad", extra, or if they have incorrect annotations, +which you don't want to fix. Once a frame is marked "excluded", it will not be used +for validation. There is also an option to restore a previously excluded frame if you decide so. + +There is an option to exclude or restore frames in bulk mode. To use it, select the frames needed +using checkboxes, and click one of the buttons next to the table header. + +#### Ground Truth job management + +In the Ground Truth validation mode, there will be an option to remove the [Ground Truth job](#ground-truth-jobs) +from the task. It can be useful if you want to change validation set frames completely, +add more frames, or remove some of the frames for any reason. This is available in the job +Actions menu. + +In the Honeypots mode, it's not possible to add or remove the GT job, so it's not possible to +add more validation frames. + +![Ground truth job actions](/images/honeypot04.jpg) + +### Create + +A Ground Truth job can be [added manually](#configuring-quality-estimation) +in a task without a selected validation mode or in a task with the Ground Truth validation mode, +after the existing Ground Truth job is [deleted manually](#delete). ### Delete -To delete the **Ground truth** job, do the following. +To delete the Ground Truth job, do the following: -1. Open the task, and find the **Ground truth** job in the jobs list. +1. Open the task and find the Ground Truth job in the jobs list. 2. Click on three dots to open the menu. 3. From the menu, select **Delete**. -## Assessing data quality with Ground truth jobs +> Note: The Ground truth job in the "Honeypots" task validation mode cannot be deleted. -Once you've established the **Ground truth** job, proceed to annotate the dataset. +### Import annotations -CVAT will begin the quality comparison between the annotated task and the -**Ground truth** job in this task once it is finished (on the `acceptance` stage and in the `completed` state). +If you want to import annotations into the Ground truth job, do the following: -> **Note** that the process of quality calculation may take up to several hours, depending on -> the amount of data and labeled objects, and is **not updated immediately** after task updates. +1. Open the task and find the Ground truth job in the jobs list. +2. Click on three dots to open the menu. +3. From the menu, select **Import annotations**. +4. Select import format and select file. +5. Click **OK**. -To view results go to the **Task** > **Actions** > **View analytics**> **Performance** tab. +> **Note** that if there are imported annotations for the frames that exist in the task, +> but are not included in the **Ground truth** job, they will be ignored. +> This way, you don't need to worry about "cleaning up" your Ground truth +> annotations for the whole dataset before importing them. +> Importing annotations for the frames that are not known in the task still raises errors. -![Add new job](/images/honeypot05.jpg) +### Export annotations -### Quality data +To export annotations from the Ground Truth job, do the following: + +1. Open the task and find a job in the jobs list. +2. Click on three dots to open the menu. +3. From the menu, select **Export annotations**. -The Analytics page has the following fields: +### Annotation management - +Annotations for validation frames can be displayed and edited in a special +[Ground Truth job](#ground-truth-jobs) in the task. You can edit the annotations manually, +use auto-annotation features, import and export annotations in this job. -| Field | Description | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Mean annotation quality | Displays the average quality of annotations, which includes: the count of accurate annotations, total task annotations, ground truth annotations, accuracy rate, precision rate, and recall rate. | -| GT Conflicts | Conflicts identified during quality assessment, including extra or missing annotations. Mouse over the **?** icon for a detailed conflict report on your dataset. | -| Issues | Number of {{< ilink "/docs/manual/advanced/analytics-and-monitoring/manual-qa" "opened issues" >}}. If no issues were reported, will show 0. | -| Quality report | Quality report in JSON format. | -| Ground truth job data | "Information about ground truth job, including date, time, and number of issues. | -| List of jobs | List of all the jobs in the task | +In the Ground Truth task validation mode, annotations of the ground Truth job do not affect +other jobs in any way. The Ground Truth job is just a separate job, which can only be +changed directly. Annotations from **Ground truth** jobs are not included in the dataset +export, they also cannot be imported during task annotations import +or with automatic annotation for the task. - +In the Honeypots task validation mode, the annotations of the GT job also do not affect other +jobs in any way. However, import and export of **task** annotations works differently. +When importing **task** annotations, annotations for validation frames will be copied +both into GT job frames and into corresponding honeypot frames in annotation jobs. +When exporting **task** annotations, honeypot frames in annotation jobs will be ignored, +and validation frames in the resulting dataset will get annotations from the GT job. + +> Note that it means that exporting from a task with honeypots and importing the results back +> will result in changed annotations on the honeypot frames. If you want to backup annotations, +> use a task backup or export job annotations instead. + +Import and export of Ground Truth **job** annotations works the same way in both modes. + +Ground Truth jobs are included in task backups, so can be saved and restored this way. + +Import, Export, and Delete options are available from the Ground Truth job Actions menu. +[Read more](#ground-truth-job-management). ### Annotation quality settings @@ -181,48 +365,225 @@ three dots. The following window will open. Hover over the **?** marks to understand what each field represents. -![Add new job](/images/honeypot08.jpg) +![Quality settings page](/images/honeypot08.jpg) Annotation quality settings have the following parameters: -| Field | Description | -| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Min overlap threshold | Min overlap threshold(IoU) is used for the distinction between matched / unmatched shapes. | -| Low overlap threshold | Low overlap threshold is used for the distinction between strong/weak (low overlap) matches. | -| OKS Sigma | IoU threshold for points. The percent of the box area, used as the radius of the circle around the GT point, where the checked point is expected to be. | -| Relative thickness (frame side %) | Thickness of polylines, relative to the (image area) ^ 0.5. The distance to the boundary around the GT line inside of which the checked line points should be. | -| Check orientation | Indicates that polylines have direction. | -| Min similarity gain (%) | The minimal gain in the GT IoU between the given and reversed line directions to consider the line inverted. Only useful with the Check orientation parameter. | -| Compare groups | Enables or disables annotation group checks. | -| Min group match threshold | Minimal IoU for groups to be considered matching, used when the Compare groups are enabled. | -| Check object visibility | Check for partially-covered annotations. Masks and polygons will be compared to each other. | -| Min visibility threshold | Minimal visible area percent of the spatial annotations (polygons, masks). For reporting covered annotations, useful with the Check object visibility option. | -| Match only visible parts | Use only the visible part of the masks and polygons in comparisons. | +| **Parameter** | **Description** | +| - | - | +| _General reporting_ | +| Target metric | The primary metric used for quality estimation. It affects which metric is displayed in the UI and used for overall quality estimation. | + +| _Immediate feedback_ | | +| - | - | +| Max validations per job | Configures maximum job validations per assignment for the {{< ilink "/docs/enterprise/immediate-feedback" "Immediate feedback" >}} feature. | +| Target metric threshold | Defines the minimal quality requirements in terms of the selected target metric. Serves as an acceptance threshold for the {{< ilink "/docs/enterprise/immediate-feedback" "Immediate feedback" >}} feature. | + +| _Shape matching_ | | +| - | - | +| Min overlap threshold | Min overlap threshold used for the distinction between matched and unmatched shapes. Used to match all types of annotations. It corresponds to the Intersection over union (IoU) for spatial annotations, such as bounding boxes and masks. | +| Low overlap threshold | Low overlap threshold used for the distinction between strong and weak matches. Only affects _Low overlap_ warnings. It's supposed that _Min similarity threshold_ <= _Low overlap threshold_. | +| Match empty frames | Consider frames matched if there are no annotations both on GT and regular job frames | + +| _Point and Skeleton matching_ | | +| - | - | +| OKS Sigma | Relative size of points. The percent of the bbox side, used as the radius of the circle around the GT point, where the checked point is expected to be. For boxes with different width and height, the "side" is computed as a geometric mean of the width and height. | + +| _Point matching_ | | +| - | - | +| Point size base | When comparing point annotations (including both separate points and point groups), the OKS sigma parameter defines a matching area for each GT point based on the object size. The point size base parameter allows configuring how to determine the object size. If set to _image_size_, the image size is used. Useful if each point annotation represents a separate object or boxes grouped with points do not represent object boundaries. If set to _group_bbox_size_, the object size is based on the point group bounding box size. Useful if each point group represents an object or there is a bbox grouped with points, representing the object size. | + +| _Polyline matching_ | | +| - | - | +| Relative thickness | Thickness of polylines, relative to the (image area) ^ 0.5. The distance to the boundary around the GT line inside of which the checked line points should be. | +| Check orientation | Indicates that polylines have direction. Used to produce _Mismatching direction_ warnings | +| Min similarity gain (%) | The minimal gain in IoU between the given and reversed line directions to consider the line inverted. Only useful with the _Check orientation_ parameter. | + +| _Group matching_ | | +| - | - | +| Compare groups | Enables or disables annotation group checks. This check will produce _Group mismatch_ warnings for grouped annotations, if the annotation groups do not match with the specified threshold. Each annotation within a group is expected to match with a corresponding annotation in a GT group. | +| Min group match threshold | Minimal IoU for groups to be considered matching, used when _Compare groups_ is enabled. | + +| _Mask and polygon matching_ | | +| - | - | +| Check object visibility | Check for partially-covered annotations. Masks and polygons will be compared to each other. | +| Min visibility threshold | Minimal visible area percent of the mask annotations (polygons, masks). Used for reporting _Covered annotation_ warnings, useful with the _Check object visibility_ option. | +| Match only visible parts | Use only the visible part of the masks and polygons in comparisons. | -### GT conflicts in the CVAT interface +## Comparisons + +### Tags + +The equality is used for matching. + +### Shapes + +A pair of shapes is considered matching, if both their shapes and labels match. +For each shape, spatial parameters are matched first, then labels are matched. + +Each shape type can have their own spatial matching details. Specifically: +- bounding box - [IoU](https://en.wikipedia.org/wiki/Jaccard_index) (including rotation). + For example, for a pair of bounding boxes it can be visualized this way: + + ![Bbox IoU](/images/quality_comparison_bbox1.svg) + +
`IoU = intersection area / union area`.
+ The green part is the intersection, and green, yellow and red ones together are the union. + +- polygons, masks - IoU. Polygons and masks are considered interchangeable, + which means a mask can be matched with a polygon and vice versa. Polygons and masks in groups + are merged into a single object first. + If the [_Match only visible parts_](#annotation-quality-settings) option is enabled, + objects will be cut to only the visible (non-covered) parts only, which is determined by the + shape z order. +- skeletons - The OKS metric [from the COCO](https://cocodataset.org/#keypoints-eval) + dataset is used. Briefly, each skeleton point gets a circular area around, + determined by the _object size_ (bounding box side) and _relative point size_ (_sigma_) values, + where this point can be matched with the specified probability. If a bounding box is grouped + with the skeleton, it is used for object size computation, otherwise a bounding box of + visible points of the skeleton is used. + + For example, consider a skeleton with 6 points and a square bounding box attached: + + ![Skeleton OKS](/images/quality_comparison_skeleton1.svg) + + In this example, the _Sigma_ parameter is `0.05` (5%) of the bbox side. + Areas shown in the green color cover ~68.2% (1 sigma) of the points, + corresponding to each GT point. A point on the boundary of such an area will have ~88% of + probability to be correct. The blue-colored zone contains ~95% (2 sigma) of the correct points + for the corresponding GT point. A point on the boundary of such an area will have ~60% of + probability to be correct. These probabilities are then averaged over the visible points of the + skeleton, and the resulting values are compared against the _Min similarity threshold_ + to determine whether the skeletons are matching. _Sigma_ corresponds to one + from the [normal distribution](https://en.wikipedia.org/wiki/Normal_distribution). + +- points - The OKS metric is used for each point group annotation. Same as for skeletons, + _OKS Sigma_ determines relative point sizes. The _Point size base_ setting allows + configuring whether points in point groups should use the group bounding box or the image space. + Using image space for object size can be useful if you want to treat each point + as a separate annotation. +- polylines - A pair of lines is considered matching if all the points of one line lie within + a "hull" of the other one. The "hull" is determined as the area around the polyline, such as + if the line had some "thickness". For example, the black polyline can have a hull shown in + the green color: + + ![Polyline thickness and hull](/images/quality_comparison_polylines1.png) + + The line thickness can be configured via the _Relative thickness_ setting. + The value is relative to the image side and determines a half of the hull width. +- ellipses - IoU, described in more detail above. + +> **Note**: 2d cuboids are not supported + +### Tracks + +Tracks are split into separate shapes and compared on the per-frame basis with other tracks +and shapes. + +## Quality Analytics + +> **Note**: quality analytics is a paid feature. Please check how to get access to this +> functionality in the {{< ilink "/docs/enterprise" "Paid features" >}} section of the site. + +Once the quality estimation is [enabled in a task](#configuring-quality-estimation) +and the Ground Truth job is configured, quality analytics becomes available +for the task and its jobs. + +By default, CVAT computes quality metrics automatically at regular intervals. + +If you want to refresh quality metrics (e.g. after the settings were changed), +you can do this by pressing the **Refresh** button on the +task **Quality Management** > **Analytics** page. + +> **Note** that the process of quality calculation may take up to several hours, depending on +> the amount of data and labeled objects, and is **not updated immediately** after task updates. + +![Quality Analytics page - refresh button](/images/honeypot11.jpg) + +Once quality metrics are computed, they are available for detailed review on this page. +Conflicts can be reviewed in the [Review mode of jobs](#reviewing-gt-conflicts). +A job must have at least 1 validation frame (shown in the **Frame intersection** column) to +be included in quality computation. + +### Analytics page contents + +The Analytics page has the following elements: + +![Quality Analytics page](/images/honeypot05.jpg) + + + +| Field | Description | +| - | - | +| Mean annotation quality | Displays the average quality of annotations, which includes: counts of the accurate annotations, total task annotations, ground truth annotations, accuracy, precision, and recall. The currently selected _Target metric_ is displayed as the primary score. | +| GT Conflicts | Conflicts identified during quality assessment, including extra or missing annotations. Mouse over the **?** icon for a detailed conflict report on your dataset. | +| Issues | Number of {{< ilink "/docs/manual/advanced/analytics-and-monitoring/manual-qa" "opened issues" >}}. If no issues were reported, 0 will be shown. | +| Quality report | Quality report in JSON format. | +| Ground truth job data | Information about ground truth job, including date, time, and number of issues. | +| List of jobs | List of all the jobs in the task | + + + +![Jobs list](/images/honeypot12.jpg) + +### Problem Reporting + +CVAT reports 2 possible error types: errors and warnings. Errors affect the resulting quality +scores and highlight significant problems in annotations. Warnings do not affect the resulting +quality metrics, but they still can highlight significant problems, depending on the project +requirements. + +| **Problem** | **Type** | **Description** | +| - | - | - | +| Missing annotation | error | No matching annotation found in the regular job annotations. [Configured](#annotation-quality-settings) by _Min overlap threshold_ and shape type-specific parameters. | +| Extra annotation | error | No matching annotation found in the GT job annotations. [Configured](#annotation-quality-settings) by _Min overlap threshold_ and shape type-specific parameters. | +| Mismatching label | error | A GT and a regular job annotations match, but their labels are different. | +| Low overlap | warning | A GT and a regular job annotations match, but the similarity is low. [Configured](#annotation-quality-settings) by _Low overlap threshold_. | +| Mismatching direction | warning | A GT and a regular lines match, but the lines have different direction. [Configured](#annotation-quality-settings) by _Compare orientation_. | +| Mismatching attributes | warning | A GT and a regular annotations match, but their attributes are different. [Configured](#annotation-quality-settings) by _Compare attributes_. | +| Mismatching groups | warning | A GT and a regular annotation groups do not match. [Configured](#annotation-quality-settings) by _Compare groups_. | +| Covered annotation | warning | The visible part of a regular mask or polygon annotation is too small. The visibility is determined by arranging mask and polygon shapes on the frame in the specified _z order_. [Configured](#annotation-quality-settings) by _Check object visibility_. | + +### Quality Reports + +For each job included in quality computation there is a quality report downloading button on +the [Analytics page](#analytics-page-contents). There is also a button to download the aggregated +task quality report. These buttons provide an option to download a Quality Report for a task or job +in JSON format. Such reports can be useful if you want to process quality reported by CVAT +automatically in your scripts etc. + +![Download report](/images/quality_download_report.png) + +Quality Reports contain quality metrics and conflicts, and include all the information +available on the quality analytics page. You can find additional quality metrics in these reports, +such as _mean_iou_ for shapes, confusion matrices, per-label and per-frame quality estimations. + +Additional information on how to compute and use various metrics for dataset +quality estimation is available [here](https://en.wikipedia.org/wiki/Confusion_matrix). + +### Reviewing GT conflicts To see GT Conflicts in the CVAT interface, go to **Review** > **Issues** > **Show ground truth annotations and conflicts**. -![GT conflict](/images/honeypot06.gif) +![GT conflicts review - enable](/images/honeypot06.gif) -The ground truth (GT) annotation is depicted as -a dotted-line box with an associated label. +Ground Truth annotations are displayed with a dotted-line border. +The associated label and the `(Ground Truth)` marker are shown on hovering. Upon hovering over an issue on the right-side panel with your mouse, -the corresponding GT Annotation gets highlighted. +the corresponding annotations are highlighted. Use arrows in the Issue toolbar to move between GT conflicts. -To create an issue related to the conflict, -right-click on the bounding box and from the +To create an issue related to the conflict, right-click on the bounding box and from the menu select the type of issue you want to create. -![GT conflict](/images/honeypot07.jpg) +![GT conflicts review - create issue](/images/honeypot07.jpg) ## Annotation quality & Honeypot video tutorial diff --git a/site/content/en/docs/manual/basics/quality-control.md b/site/content/en/docs/manual/basics/quality-control.md new file mode 100644 index 000000000000..af9d8a5837cf --- /dev/null +++ b/site/content/en/docs/manual/basics/quality-control.md @@ -0,0 +1,109 @@ +--- +title: 'Quality control' +linkTitle: 'Quality control' +weight: 21 +description: 'Overview of quality control features' +--- + +CVAT has the following features for automated quality control of annotations: +- [Validation set configuration for a task](#how-to-enable-quality-control) +- Job validation on job finish ("{{< ilink "/docs/enterprise/immediate-feedback" "Immediate feedback" >}}") +- [Review mode for problems found](#how-to-review-problems-found) +- [Quality analytics](#how-to-check-task-quality-metrics) + +In this section we only highlight the key steps in quality estimation. +Read the detailed guide on quality estimation in CVAT in the +{{< ilink "/docs/manual/advanced/analytics-and-monitoring/auto-qa" "Advanced section" >}}. + +## How to enable quality control + +{{< tabpane text=true >}} + +{{%tab header="In a new task" %}} + +1. Go to task creation +2. Select the source media, configure other task parameters +3. Scroll down to the **Quality Control** section +4. Select one of the +{{< ilink "/docs/manual/advanced/analytics-and-monitoring/auto-qa#validation-modes" "validation modes" >}} available + + ![Create task with validation mode](/images/honeypot09.jpg) + +5. Create the task +6. Upload or create Ground Truth annotations in the Ground Truth job in the task +7. Switch the Ground Truth job into the `acceptance` stage and `completed` state + + ![Set job status](/images/honeypot10.jpg) + +{{% /tab %}} + +{{%tab header="In an existing task" %}} + +> For already existing tasks only the Ground Truth validation mode is available. If you want +> to use Honeypots for your task, you will need to recreate the task. + +1. Open the task page +2. Click the `+` button next to the job list + + ![Create job](/images/honeypot01.jpg) + +3. Select Job Type **Ground truth** and configure the job parameters + + ![Configure job parameters](/images/honeypot02.jpg) + +4. Upload or create Ground Truth annotations in the Ground Truth job in the task +5. Switch the Ground Truth job into the `acceptance`stage and `completed` state + + ![Set job status](/images/honeypot10.jpg) + +{{% /tab %}} + +{{< /tabpane >}} + +## How to enable immediate job feedback + +> **Note**: This feature requires a configured validation set in the task. Read more +> in [How to enable quality control](#how-to-enable-quality-control) and in the +> {{< ilink "/docs/manual/advanced/analytics-and-monitoring/auto-qa#configuring-quality-estimation" "full guide" >}}. + +1. Open the task **Actions** menu > **Quality control** > **Settings** +2. Set **Max validations per job** to above zero. 3 is a good starting number. + + ![Configure job validations](/images/immediate-feedback-quality-settings.png) + +3. Save the updated settings +4. Assign an annotator to an annotation job +5. Annotate the job +6. Mark the job finished using the corresponding button in the menu +7. Once the job is completed, you'll see the job validation dialog + + + +Each assignee gets no more than the specified number of validation attempts. + +Read more about this functionality in the +{{< ilink "/docs/enterprise/immediate-feedback" "Immediate Feedback" >}} section. + +## How to check task quality metrics + +1. Open the task **Actions** menu > **Quality control** +2. (optional) Request quality metrics computation, wait for completion +3. Review summaries or detailed reports + + ![Quality Analytics page](/images/honeypot05.jpg) + +Read more about this functionality +{{< ilink "/docs/manual/advanced/analytics-and-monitoring/auto-qa#quality-analytics" "here" >}}. + +## How to review problems found + +1. Open the task **Actions** menu > **Quality control** +2. Find an annotation job to be reviewed, it must have at least 1 validation frame +3. Click the job link +4. Switch to the **Review** mode +5. Enable display of Ground Truth annotations and conflicts + + ![GT conflict](/images/honeypot06.gif) + +Read more about this functionality +{{< ilink "/docs/manual/advanced/analytics-and-monitoring/auto-qa#reviewing-gt-conflicts" "here" >}}. diff --git a/site/content/en/images/honeypot09.jpg b/site/content/en/images/honeypot09.jpg new file mode 100644 index 0000000000000000000000000000000000000000..95a53f3fe3ac6264af1ea2e4bb44f4832802b7eb GIT binary patch literal 31966 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 B!5jbp literal 0 HcmV?d00001 diff --git a/site/content/en/images/honeypot10.png b/site/content/en/images/honeypot10.png new file mode 100644 index 0000000000000000000000000000000000000000..8d77b290a6b95c4616d67de5fcaba4be2182cf80 GIT binary patch literal 39059 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 literal 0 HcmV?d00001 diff --git a/site/content/en/images/honeypot12.jpg b/site/content/en/images/honeypot12.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a018e4749d95df399f068514656bc04989e1846f GIT binary patch literal 22296 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 D$Hr3t literal 114770 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

Aa>i0X# zYhW|J(a0uq7^F9vyGwh>9`+LIa;J{o)yM}Fk{)X zEOnbJEoa-unz%;YKRpAj>%_GNn6u)LWEnSmhgp3EM!NAp2GH!f3zTh&yy!-3i>l;m zx6ykxuFbm5=DxtyBBVKBR?-^LmwOmRcD!DYU`zLujI|Y%3ma~v95TMHNH9p(6tLQ` zOr}{Uu^6yJ8{I#QkTHMbksG#V8!s{H6#!j3lCX0Tup}8tmXas#Wk9Bb)F(K5+_s8f z#H;+YD21hpcU7~N^Wk6sy~+AFv*XQBWA3^!#+^FBCAZEc!-dfdR9C1tN|<+Ld&nw|BkT?9LH_3q?9#r<&8bl7){aV^h$;VoR6N6n6(VWEfXMjLX5#x z()(!!8gvh+boH64_b&$6akZxcW^lUN1Y==Mcb%(#!I7?+tfGGFSaV~0e_MYQNE}YL z|7?9P%)l;5Iqe~UMI+qzSTiKo6ai+{0>OJk+7UI0utkB_s^*~+fld4xrSO4a;w+75 z*zWUbXs~iQVQrFPyltZZ0W>mwn$MsWhXm4F@h~>>jU{K0@ovxFkZv|vm;WQ~7?U5_ zN$eky6TY%Cq?*h|6mu?Vf9RSK(XbP8`b=e6-B0Wax!zJ>z6ZEm)R<#k%UjcT0Xfwi z9HJEiidr}W(!k6_mjRse^@4#hex=zv+9@=8hPe~DpN0X8koHf)ol;tSsX>zgz@gYI zWh8F8lqSeQqk}D~s6hSr*KCvRk&|ws!^;RwKiar)Qpa58jGjku8gr*$^O^7mXLYcT zG9m3?%I9}->QTwi+mI*HyHPHGc^tYO-pt^EJ+kN6FWhf0VMOu@W{>}t*`HNBt@@hl zT*vNA&F@EB8m(+~QscPysdo%;-&b@PLUYx&RK&;*Xick?FxRKSA|_z2jJYH}o&D5t zJlk<&X(#xR7F{0MkSA0ZH`thMfxe<_{04ssi%aW zX2OsK1k#(Y#uG}P+ZiU1VX-=4$L7+lWx36|x<&MaL3n6%@AO-W zj)puD4voN?I$E224GEhnQ7LmWRv8*fnG(Cen#;B~NsF0hHPuR(1D~mpCZySEN;=GU zAF`I~NwdUL>D)@w(G0MbHK*p{`W!LZ*auCB;H7KX$G1T4Dtd$Y5tUt)OvRqYVur_$ zc~Eu}cF-wVXrxv2hZgDkIIY;63TAc4cTgMu!7}i$hq6&@;HjJsFV}3u#Y_W@4z5rS z$whJMEjlW<=|8f6Ub`3}F!6xpZPaitVkBs_OHIWpq(^Jh^@1YC8q8PS%aoyNR`V6| zIu$v~_3ln`$4o&p*;L?^Hm7!_fF;)xdzm#ivo3mlb>E@YU^+3u{n}}F)k=oMK)mL7 zI||y-S23eo%>5Lg|IffihGakM7}C`&ZThb6OKCl*D3?77+UOQ4-1Hy*2F0y9bu*0) zMnGn0Mq3`}^&MyuU&UsmW`CiKR7FM(qf*x4SHK`Vk1=~2(8Pl&r|w&eGEEk}19(mW zs$?}CAUYlS7=!eTM`;Ad2D^3!mv;;?*g!=^%*w{61+Qp?~8#=;6y!URYAWHz}r-!q-S9sEIwMX&Bd z`{eYqa!7P+7tl#O_)hK{+20q`rx)~E73Xg&P7OWR+8m|PP&Df<50h53s`0tWm>le0 z)t`nce+2JrV&|FX!L)rc7#TdjB1mglXV9m1YUCF=(@zgkIn;Iv4Hp?pX?+>>B< z5TbFRjwh3rBbx#g$*S-XrCkovT}}pyXdJNY(b40XNO%_a{557i<`Gdd2?4_CAhhLs z5gm9pzJ^vBFhTqNuk)H4s&NdbG(S2sW6iPGm+CLXbnIWj~`opW?89F z^NYO`bz&V61X!*HA}*K(w2)p3efjjtz}T_k&F9XN(OqOBfP0HSrl_rmpXSu)VcW-m zIX?wqHJ=_SN@~=PpR3+bA@X&IXk_qO|4KJp0n*A&@JmIZOtzw!=T(TXe!&*FCe@+J zVK7os2M}x$cRIFa0$HVWOy@zrD#4rCL;D#jWbXcYf}NuFKn}aXb$V!cD#kT^HPC29 z#{~`OZY1}T&~VWKR)aHXjK#%&-m3Xfz$}QqPQhcs*XrNh1wo$9eW#H>@Z!owTNgb46M(pMm<9Jao_+IX4Qd ztn;jdsMroHvU-Ef*XdOPyisXA6?`BYBlcB(U>Z-?LGJuVwK5v7*?KVr-k_qa7~0M5 zUAJ|j!WaikixVV4ti=loX3`|upcFr~PKH5H;D;&a8A`C$Ic2%qN-uCUmn%6-4s_-(JCXyw{iRZhj{hX{U=}GMy8XHb! zIM4NP7A)3kpT&y^!PsnE$VHJFy-w49%3nK9@cO$)+h+}Ch3vml>S)eGVS(oy4JJkz zthp^fFfxdpq5K+{ErPhyu;*_T%{mTp$#7!S@rq)YGW-{82`&!gx`7p!tfoZtsOBD? z0O!vT?+n;ga!M2P41@fPuL$^qybcm^+;8bSF3kem zsR+IurSOG|C7yvkx+RNo9||(8(oYPYoGTmI;TWtEJ^{yE+rbWtGhQcYgO>T)VjFObW(EmQHI8uooe=|1 z4%{ay+$ZjZlV#LJ&voQ->^qJzvZBLUDf%kDMW2WOR~MG-3ru55KrP4$SSVwT1sr6P z-ul>19N!!3U2K7|o`O&vL-OCWOTkh#=_6Bud%3r}J=Ybs| z${vtPX9ZHLg!b!{f$M;dKk(W+fM~T_b`r^|Z7UZWDq7V@S#jdc<2c5rODevR(|B_h zSImqQGWQ`!|HlHrB|Hl$_$YPcCL!nCUIS9f*!)TMI|sN2Cj#j)g;sR)_$VpZ7Gf4;pd_c0lx^LG z{C`zf>?_8Bv&wzhI?56{PE?XWrr>zZrM=x9r(4Wic&zzjvO*!MCjsB(&zyV{2i>h> z@Hhe$2)ZozlolBElVnvoaNKJ*VSMJw4E=;$nB1AzFKokM+18Xr^l!33h%KZSisx5$ z%KIxsX;z-pdW0@GS7Om%F;0V!4V$B;V2oBnp7aU(s{SyLYw)jr>tJ~e8ljE5q3R00 zkJ7l5eS>I&?gpxBm)r_`)Px4b!F;*+9J2dz)a+$B!^P4OqS7PfurgtFL?)$^HY@vEjAG<7MVKq;?4ho6pk!h+Xp z16ClUKa=_Mwegn%s?PylArAUmHNRPba6t+FzuN<_u&4_jj6`6yOwu92=lI|G4*-l3 ze?S?G7)Zf91+2|wqJJ9LxlZI$L7fZk*He8trxiShrvkq!YX3rBNMcqtS1Mjm=N8>) zXQ)3G0zh0ob?&8yKUoQ|9RKgHrG&#DDYcQ#eFZclS$=*e=rz}q5O4|%Z3bjVWg)RV za>@NdibujLl(4xdjk`YQNtBuC^@EqLFQ`_EOP>k?o-6Uz*yO zbr9nRK9WVxG8qmiyZ1eI*Kqh{p0-z_dm|FZe=T71!K;CO9-ZJXy5j80J_ZW#FV=6I zoHR`c`*|DI)Un>udV`>S5V$?Gv!oi42woOwQ~LSsYa^KQoNJ(*e=AbLb zSpuM1WHjpr9ptu8Z<+-A==QH?gN+jaR+Kzkw=~gQiD`YY6pDrYG4xPp7`~iu4#faS$aTsKkquOC?HVy zKVN2DE$+f{+%)VHDZ~k)kMAETeE9vE*6Zm+qWl-}T$2uhgeGAjp>%ixGlA#e-~p$& z705vOew{HS{G7;j3V~EQ?htH?{4J1qfZfW5GeZQueJkKXlwg?V__v?PK|cX0unxpv zj^bwCc+{K`b#Tc@C37AUio0YE@kAg62vUEa_*E-N_=!IYouUnSH}=Z}upVmQJ~WyB zTgF1inndj+1jvV~- zfve7qdeST*`scT6>R|O{2c1*PW3U3SIoF=1nlwr9caZu?f09r}*Qm&$xQOZA0z;S| zi-_I=coD|R_ZW>6ZlWxm|9nhz?ZY_6fGn#Ey%`iO!3ZPy74nNhLPE!#-mC{LbGGG) zRyR2j4pReRvrzLV1o?yiSSE_zk0FI26Ifs@Wd9?B~^%PsozdL*hXc>aIf zauA;i^#K3ky zqHEAV@fSV+|BZCB|M~8@|L8#57vl+H4^d)T2EY=nwTs`t-kNkmdJ-C(j-R4$pZ6F4 zb$QWxNY)F2L>4jIE;>xr6^Kjff+!P>;9o#pB9t98-yQhB_e_1*gwr&k<_P})bk|*o z;6fQEY*SJryIp5C%#7I5O(zus9?nsp=q;1 zbjHOvR}>&%2a1s?BINz&VU7tuyiHvIW(tkmwV-oT0U0rlA!z`6C`Zti@=a{6U22oH zD5mp(`7b922}~v^UhX_x!7fHCl5ShH+i9X>d?70g1Hz+HXLy=(V)YR>%MsEuru+G{GEA6g%yqm_nFjD zKImfp&S6P%v)BaAKb8?08eSrOChX>jumJ6*1S9`qWI=kIHH*%lj%iCO+C}&>4FMzF zXPnVRI)>w!|Mr_1Ag#_50uw6iwAYa@>BLp%4hf??$o8`Wl&4Vq$okiwGEc6&&hEe| zOdO}7iX5_R>G<7@vpz+&obCAYLR3WwvA=uRcMQ+&RsfbQ3 zc|LDIrNT-L8qxg>7Oq$m_6fchs}WkNG(}vD1RhXzj{aD?vA6R>4XMZl`tCG{>;e8Y zIOfr@P7NDd_!%er{i&+~1J6Q|orN2g5|W`hCFV$ElVm&tH_W@`I^^hcI|Z8$_r`8F zl=ruxZXNAgxcxafM*99K&_;}WTmFkdeD;~_%au;0WwB2ES=m{omXlj22I&i)iVRpj z-FAoF0M1E8i)B55c5X0TEP3cx+5edTwCu76(dv1xiYTYoqbKjWd5*54?B0o0HI8vN znu~Fn5x>PZO#k6*9ofYa@$<^oblb$4f$_x`$~9;v%BbVqQ9ytsw8CN$i_kq~BIE$Z z{mLtiBS@0g+y-t35RL09q7je$9JVI`>_I3-ZO;hwN1e`HD$$MpmgZD%!jPE?0b);o z$6$1GX;Pifg95^uK|+ZSWSD_2`HFkLm4VZZbs{%S?C0rL{iudy7;Br zumI!n3-Nst@G$ODH#*4B?+q+hNmMOvV*2qUN~G7=>Z-X3RiagkaSrw(Cq|IR~ujZSq`she(PH(0Lbr;lLO>S%G^2#27p!HRT<__C6E!1M1`#$> zY>af784O_U_a4Vcud$Y~7{T%2X4u4k*^F>g!_FKzxds_$`r*S{Dp^{mhwa20)H@J% z-98hRqETrP&?JmNVTM8!l=QxF60#DTn;UFwVfc14(B=uen=h%VVb1oJ2NhPpgr_Or zMCq$-RKda#cK<8WI*kV$$m=)M%fxI#L8Z=HokdWtRHa*XReO3+RXYj)MWf(=+xBQ> zSc>9?S6|W(g6Hg>uTY`2a7g{aCpd4A%dO1#@;^sb*uisD*oX0C)Gfk&MpHm_HGjR2 zkS#q5ygaE~+IgkdW?H(N0V%bbN<5K}Q z$s5vJSIBQ+GoEBK3&I>kl;EHwjVWTBn0}f{J0!b}Rw{FtoZZJrxQbkkPBkpXPDm|= z5@iZ|;S(VHR9E6U_Z>ca_biLa-+z~?8meF zuK>LWTz;dwVriq~KDO$O)|*Q+_2<)M6waKYQW;sV-#V3=fa2Iziy))P%}ARea2y`Y zsqNuN>%4bJj4F143hF|GzyO#KmbPe%7^$pqubS=#UC`22->B#FdffLBTuPp{PjTwn zrvksrSDr&*t_C&*rI++1atm%z-pk4^r{sBtP!DJD;)E4m@6vu4ufG6F%XSI6i*cfC zi!UAK8$6K3aAI=NSoPZ|V?j1^%kgSif@jOAd+{J#ILrR$(|Th#XmiV) zAn=V#VZXEG=G3vgmDae(Dnrr1%W0gaYugT5wkUrV%MNYxa084jr>tBDvklO_>j2Zp zmBtDVJ%buOc}sp&U5!)|sq`f1QbUU!RV4|YAj}QA!Rc?rd|f-xM|o~B*e*!@GN%l$ z3VxKD=c#r55-19-$Upd6%AWWw=h&MNGT-hpa&9@QB=8Fs(Y%Rv$L)0N>~#wWLF=C{1g*(xbc}(7 zyP4-6b3b^};f%|Sd1|%jUV8C~fktWO;pK>n$*v&7`;O!^2e~nuId`$@X@}b@0r6}l zAHoK(V_75N?3?_WiG3JNw9m_1W|Z}47Zh_T2bgK!G#n{!bUCN%(Za&tB}#G}Jd(!* zMKpFY9jIA>j63tRR%bO-OM3^etJsmrncI!!V0|k-PN0v6NBk`8W_s<~D}hM~{GyDU zFOKgx3?$`V<%>32tGokq3KV={@EA#kd1CnDPiF-+pu3-jp7Ou{J z65nr^W}xZR0!1jYmP^Bw%|I4}sH$RPRKLXng(aH&$|1Bz_g3tq#_DM?QK_bb!az*4 zNjI_@n8|r$Pn0Bcg(;l*r}t3$VzCd;B|xMetvO&$w55dzX9fmF4XX*zEVf2EA^A96 zOMr`R$4mD6q7cxP94Ka(Y0E^#T#2Cq-Qksx)4ACapysVm(bWng3&noJ8ClDUnA>C! zatZ<{BCo|!G>YXemt1nNZEh0iXhV_GZbRtVhK)N#s9~HqVFne&${p0ET_Cwg<{n0P zs#`39^K(%yGaY4*GxLxW5=ZBrV`?L04`}P&h%*ZF{x6M&${VEiY$;@kEnvC_OIy1T zyS5-NigPE&`9hww1T<{MVt;*rqC{7Q&i_%cKc{rv2uwstkaf<^RB|NCwAab+fmNk-dQlUqQKnulez)Z%CI)h zgCQv))VrX6UhtUKd~-imCs~xSk+oMxxAmXdJm%$rA?@$5Jt_kfxUE!9Z;zZCdL+wkeml`U&H4l44*ZdNHy!rAdEVTGaDa3qPj=gay>C&v z238rkraCH~dRjd<{OAsxYAlSkt_x|b`gDWPJ-M%J(T+ZyRw*cMYCW22{El;qX$2YVkk{5t zbf3uhmOGpV<4zo*HWIjXV0Bu$yvPw-!ZlnZ=*?2&9I z+=0EbYx3axR{&Yl6>`tIGWh@&nNtK6%xu8jAhRomcy_LQ_>XyMF?+}a30IRCSm#gn zz3w}M$E-kul(I5%Qy_Ljs&H{wetiO7YfIM%^`-U9!ymE7xc6Nld?Khpt7s4354fd8 z`nah{kkUZA942!mqSNSeBMNk=j@C$Jud|6>aEmbt^_E1bq~z}+?!{(ueF8(XJ99Q= zW1cN5)yyw85>nnrn%CBsB=_w{ohmu4FIiYV&@U(`pyK{z?adhVHxD3~dQ80?xfW%Q zGUWzfYevDRVec}Si$pH_#Ua8WtMWBr4havbbWrN{_W2OxcB)uX(C)?W1dYpV2diJw z#^D3m$JZwUMvFm!GN#CnkwFFaYZ&#(-%>PvopItUF~l-irabNtX3o;@2xiua6eZXa zxV)}liaPQIEn9hQU!rLmnJSh9g3YdQ-*NjH z3_rlHs6pVke^%pTrcedlo;x#OGiid(kpDXHy`>z|`C$B5JTrb}AN8V9xmzP|5Bcb9IM>_760rG{r)6><0t zP}BKKQKZq*jgRYM74Z~>;pJs;U)K1ZzI7HK$rv|UyZc`n9W#LU%0RK+0Hb+Kfl`y! zjF#Um;PW8T3E06IIR(8EcSGh1*bOpKsuBvL+W6s(vzA&xSAdjalCez)w`na3{f^KqZPY8WuH9~l7$!04q3z*!gK zf%5=XC3HAKzh)0ZnK9dR7PGnd(iDIs6g+E?BKy+>`BU2jma6#?UMBY zi+Qbkq7pk7p0)%LLr?5@Nlkwc?C|d@o5n> zGzvsZ1L$RQ(o^C zvd24}stA+b6>T>RJaiQq#sKMkjq9J_Fe%?}DNjfMk2xQNJ3pZwN}70z9tJgGDW1=8?ly9G*4m zGCG&~c+h)yM(;f{cBJe2zjB#6aVcXz_La-R@_{`F2GI`wBa_s@l$+N=ZYztYte;s0 zAPYCo756Z2_B87r#9}u-%(g5AgF^GQKT@7P^lhwEN7io+1mKplqL#ZNw zT4#Ch%$SJv!!5Y&!}dk^4rgfP9ni&)j?nY>MI4sZ zhO)c5LnN5&4-TOi02F~$WC+i!@)Wp2Y`%Yq@Vb3jaf|-g3%_96b2GLbL97}7rPr0O zegVXI3p&}Bp#3ndIBwg*$Z9AJqZjTVKdUQfZ#uys8NDC~j?;}m0w>BB`OGQ;Gm96W z#981iCa1=)TAJg(p$C2rruZC?>_CVGAbm-m@`K2))SEO1`LlA;-+#TeKFsLEYv^B& zn_Y^9K4rP|0ooA+o?yYSpf9b^zr;KcLG51nxupmsG9oHdXQCW-DB#0~;I=NKxqV(>zU ziZ)>#rKTY0(rA1HjBZ?+!BYsuXUrbGQ2@%6iA7`--PM$uHzGy``>-1{BSaHfx9xN@ z65U6BUqyCS!~VMSqow~W^3a>izIyYp{HgdDkeIYtEeT5l*lsi}qWGl>xC7XSkTxx^ zy%=CcNOEocCk{3b*+s&Q(^vyylhd>f^wWX(|ykC9oW#~BW>f&+$OX&LW z?nBoQ06W<1pjzdC(tl$e?A=sW04z~Y7MoEnn4GBwUG>By>bSia!oJ1RH)bS3Fk2?F z8&tCk+@yQapX7d0#=1R{%(2evCS){kNNm8qhN~^YiakG=ur#9_SLp28o6I3wu$peu zQjzYds4ihXrds00=zLD5F?+<|$vfBU1sB~HUoLJE=y@Eyhm65uuco;Mnx79k&>33UDZrJFotbI#q>4PLNWgwzmL%|I0bQ zk+bfe3bXHuF4r@7S5nvaOXqv^=m1@6EetP7B;N z@g!#MuWs(q2bvvU&>4F79K5%|%a@WqKvJcr9n*a&G-#rwCD)ox!|eu&GiRA1qWLGz zCViz*I%B1~i<77Lmg2e3Pdfkj9BOhWF=E&8q9S`Dzx8e)(!B9qL&;)d%3az;qn4`_ zckl=VuAr4W)2F%kMr>2Cu|Br^LkZM_Ns@Mi9V2bIb2HE9c|$n|CSKlmbRfIhc(8-| z+Xokk)pGs#$y;bAWN_hI3~baEF95lcw24p79VT`Os3IG`d*$2AS5$LX&=;NDc?yMe zT?lk;5_drm_Wq=;3mhNL$YbGE(17!_)iTNt5|JP#x-|CJ{ZRi!qM^%wLh4Pf7sCb)Q9_4Gq~8Cxye!N>9pu_$H7NFk$aPQB+x zYNaLRWb7j%$#x^jzd(7nk?r0r?>1tX8P213|v5h<@liMB)z8@j=hJ%+gI z(N%C82OU3YW+pz(BQD~QKXTuo@}@>C*V?&Wn#m57vO^3457N=8UkqBl3%04&nS5bT zAO-JU%_;Du8eD=~a$+a&RriRS5s496@dJ&~xl8zO1pF71?KW6lzq}y{O$M6}{+3E= z#fXy;vhaAsl4bj@(@`cDFrTKl7qYI*crR2FoY9cv$KGA`j7P3tT1u~BGNyqk>R9qg zS<(XyI`mJ+24rSlmwkBBKAu6nFz_~pt-kbOx&5&|s?p5qfl-?3>cy?+uzh#(g9@ny zQk-qS(obR>rKk>Ce8sR}S1ARp*YUZ*qtZus+>}=@1zTE{kN5id)so+|EMLsjXgB;a zcy#Ot+SO8vSO7nS;d~uD`r!yXLW;E@2I%-c=Rb@q6p#eS`Uo;L)E;0@cr+O{j@!^z z%P;Ch3w9u)wU;aUqvg{OS{J|){Qy79F&%sN*v%k+8JM-^t{-5H^1CVhWpc4~ac=*z zAJE@yH<7KWQB)7NLzo+1iMQWeE2qL$fzH0<{Z6P|;LcSb6={=TsK?pL)) zzEcj%u4s{t7^f&(3$?!I89aewW>iInc@n&YHUyTqxz81+N?bzwo}is0ABg z*LPsWPMV!sT0#--!~Xh{U*0?j;{5!&9pON=NI-B48jvstBeT6i5NG-!25O-hdMaaO zt0d%i1No*OL@5a~2EjlqQLPKi(T{Pk*SB;{)9*Qc@~3mWT)VgBy~oEmIj}>{Go`m+&hj9Sv+}Ym!DnacRc-*qV zi9M9K^kzTj>aLQa$84mgcCiDjykzTU&&vFC!pf9U9(f(Q)Qmig%jKPbKgKQ8>3}%z zsA>i39yeA;jl!OEmsX!66lfT1WIqjUS_T4e&$75ltw5ChX^C6qXFv~)hTAo5`;ZCT zgl~8u7blLB9#wr??(v-9+FI_!H(wQG=7q#3;xuMMs9#^W5Hu}eCuafR+#Aa( z+WFZ9!xWLaX+3J5#=-4%A$jJP4rQ$;oJ!t#={%(wyt6m^=|Fj=(~k7_g??ID7tB)l zckbiN#n>p!<9A6JG96*e9v`FgnKU&yG!H051wK9#xfES+?7;gI#lmN=$9pdnO(wM^ zh)uRswATRb(cGb~$%Wj zN%CTXZF^mRT@&MEmt*EDPl2{gWzF$sH}A2q<=E>@bysC%ud=JYRHPX^>h=A>sEYA* z$J6bt*sDK&Y?)V-=DL?K5|(mJ>@`ZmLnzi|tBSXucma zetYm^A4(W{?qY<_>&DnKwqu^=GMwQYR$E9+ey2?NJZDbAft2~a%-Cna30p~uCO=$~ z_Pf&yUg;{~-5mV9tJ}9Kuju<@MT+xYV&9CEMf`yt3BJ+7*vm8a&_on9K592Qdu1@1 zG~6Dt1J2V#E=^0m_aYy2iX(&z2!T<}xi^@PsY3a_Iiq8j$av7W8I*V%+To-D>L_XF8RLh|RegKeBEtOc=4J4~NHdc++H@Fk-VeRuR8 zaM02rcIM#=lC5{j3qRjkZrm(Mr2z%br=$hJ10RJbCfZ+;Dp^3FZ`;6s{*;~Unao%h zwqOjaPU=Dn?mX9?Cl_2SOK2`pQ1an!L@|)PjN`12B4(ZeSRTa^Dw-w%TgVdiPU#to zGbNKox*yDe5wX&kY-pvgnQwB?n=|&&`H5^z-K!Z9fET=f6oGe}+fDV4c(VI>LvDb? zo%@`iDXd|O@8vzi-089G#3dskyObqiR619i=E*$30}6zqXxWV=Z?%$ zbF4Dkm>%x#&Jt@;d=s5mRbe>O#<{$D60hZEh(v|4g#3@|&NNPAX`Ry~8znsM+N7WGMn zi{5=dvnph9_G1s7Y@fiOGQYW?*u~;uA4fGS@eeDKnaRPA;V8K`3r*%s;l@Ep2?7S#i z5O}ZRlvgB=_Xi{0Z?5(shaZYh#Z7LS;Zuf~~ zJeqr>M>+RndalaY(FxA@_*Kqv3H|YtXI}5@E9fZ2+OxF3FM2EZ)d9l7f{)z2F2hgB z70W+kCBs=w(j@kNZP?z;_^fHCj^bEZhTgF;@8e0|OF?~HWndqSDu2H+I7 zY&L_m%6;6`m&IX1pYhd~Qi@eqrI}GaovG~HU}Jx!jdTK%b!wN2ym#`JZDGtQj(zKN zkWw&pLEu2&SlIa0@mIEQxek;I6$Er;p4YiWQTN`7LPFtTn6K^9rwwQW3nJyg-LrMK zdFXz{!(sc06KqBE(qr!{uTPwCRPAcwp^E4no&j#@3gHWmN-30lxj#OF+n%k&y-T|d#9lPh)gf$_ z5iaM##`W&*JsxfIHD*h7kL!MSatF-OgX1u5Pp!HDFRNVaR0Vqo%#=|9o}N$Y1%A2Q6oK((rn0MB;~Zd%e3V!{&SJD_6cL#ZP7`J9+&UtMC$9$4UlSFZvjhHMH1g zR5|K~*vwL1cT$t*x#aeH%Cvyz>OIY)X_bct@EVQp@|djuVM`?vs#_H&d&e_!HeXL zM+)n~m!oCEVmJDdK}*I|Y+++jt;@ZzBf;M&t1S60Txu(PO>5n|=&f_Fr%Io+hhOAU z9}Aj+`BQ7lJ}VmL=OQDIkfWD2_9gOla{3P%hAqAu!?MAqEQt*7S53=Wuh}VCVk4SK zKLO04j}i$CGM?s5>^RzQx>qZ3_FO=6)XkYn03}oAZTj#J4yfF}E?8W2*N%aw`X+=YtVu=)vkKj47^LXQ_USX9YBfFEp}Ku z%Ohy~>KQ~xcM33AN}_T+`aV#(z{ zWA(o6?kP0flyP!X{V|D-ge2Ie6TI5TJ$|3@b+64`!FPPMOU-#J^wOZ7n69pne$-i_ zCBrr^s1Q;0k&}IJa7^8cCj3~(*6@!ihGyx>KIN$ciJjFOxAj6_W#vk>VolgS#6eP? zAsdY#;Ykx?Z21)L-BGQF+2>+i6~bnc+Eir7t(c=(_X6dp}p)N)?I{E`&n z+?x>s+!nXnj5Ti1V|#WMFTu1?Z_#AoJ!{5zYU@x*VH}By-bSezn{k)k>%8FB?KB?B zk=E-HFm2E%Pl!EC8hd3?b#qeOgVJw`*V|bw^3i4C_`*5w;)I2z15RVFysKo#x2{#4 z6j|E8>#5gNM(Rkxr4XO8EBNA?{hOh;-YV}aU>#o5Q8yHBN01(~8r#^YPR$E`to?R6 zr&YJ%&VAJbwJO}Hd~jFaJ629pCwTEu86Risc6jo3x>rT}n7YZ`msy#f6>W^${}+4j z9ToNY^@|cjl2M5X#6;1kC`E=Mf>bGXIw)1Tiik8pic$p?HHiq~F!W-fNLQMGG%=yf zNRc+uF))mPbOxlKy+=**d++a_yUsf2J$JqLp83lqQ@`bT_Wrb;TaKanV$+?>^?rf` z4Bv7E^#^Sc7W-vS9*fX^tqVJO&PENGK|nl}m>#6^Rn)I#A&?g6@u&Fs6_C<}%+XaI zUZ0uVUbFX__kAn+xv9MZx%wv)9X#$WT}NklHi@gWmS2Oi#edwVcdMRx5S<(T-8$Ietj$?l(5|PEw6Gp8 zf}X$HtHJ!0L5RY_^NY8=pwE|4KYGaE{o|2SoiJcwkvWpCwTod&FG3BmTzLg`gyDxn ze4mxiIPOlkbETyJXAgEoo;bf+9~(uRM4oI6^^=OC!r-j?c5KcI7X zKjGEDeCqF=(qW|WTkY`=G%LR*_$4+S_m6`6<@CjO()z9!`BVnxt7OYgs`zw^I54ES zrzp0?Bdb5jj}v|pm}z6K2B_T`F-X-QCylAhz)j<+^nKx$0aO6tR0A4)Mq|uU(CcaK zPbly%)N%Y7E(Ipd1p(kVwoXVcq{4t`gY6*pbcH}%rotr2s%pSkZT`=ALdiD&`Xjn} zH?2Xwoo%&LK5=tz7%X$Sj!?@pmt|li-@?e_9n0@k<5YD^wCw#pzS@z+L+~mO@g3OB zEX7HFIp7ebtviYn02y!ksb1}%Cm2zdUW}e5sh7`OXn1yY%q_t|X-gQb?%DaNerTyY zyHjBK&21nQtK03Q@sak*`^ygwK11O_T$g&$K!fj~)H1QWYlqirP|;ghM5??Xq-AO( zQ;W^!!{AT%mO~x#;N#kA`?bysZlDD*eT}4Q3xFx3{hCrRwWVh7;?$Op;6Law>C9bX z7vl*92FlIk!o!;i4p!4}l8SD*NJhn9B3sE17Qh(XR`n8eA(Rj*l4m>xYlQwtE4!*EkdB6H_qu-OO&qq=vj}ff~ z7B$$}y-M>YhKRkpr~Gz_k)|y8Uq%(EY@;a0uuk?F%4;$HwVA&?gXpx8R>W7P32J+YO~)?-O1|-K9C*yD_JWZ)o31P zchmi*MT(b(_wY!gOg%0m#z1nCJgXGdrzyubPA0= zopUzVL0uJ+QO>`urtCcTeufN5lAw1k!BxC?weo`NmKu*5*I9WUD=)_?H9>31Lu5i; zyr3f|aWP_F#4{RGDc9fGZ^UfmR9$3UzMVB_nlj6`cfqPMoIT+x2KBSny1~s;nhn<| z=gels7)gtJnY^h6q)QR2TO~ugNLtDa+O?<)oj@y4BA_4OeO+5T#uG4~WLmG8b)??E zQ~eTDU=t2yD#;#N#LJ2RJ2HJ#iPtk=lrSB4eTW5pE$8&8m@*wRt}|cXTWWv4O!eHi zySc-*sM!SLeF#fwSd>}KMlaMt@D>cM9Eq=Cpk7E6!K_T)utv+3)tYh)hO1sbm=vu; zl%=nd3>I0pdi18sOUR$@mfK0Ro7u+A7mPe=Qrt^I>4Li*Z=Z8?Q*!cjPJ)nv2*j; zd3M{s*>sVO^Wl=cTeG%ora;__xmyX_^tuOOn!4BWCJT!5dK6VulGxeORlosVQ6E*6cq%*Mx*_!0JdE&&@%Pv1_yV+tUb)}sdMnZ49`8? zA)7S#BXYu1>q(1x>AhVVHueBqDUTY8&A#V8Q9AaE()iBQGH)nNl%uUI#x4 zCR?BH!y19W^co)6tVM|0<^vugDp+8Q;jSo?1PGmVT}g;Zq*Sz= zWxWMc8{UGGw=gPedhc@Bug?%1ehq;DHV<$9D7mld2l8b2rHILhOOcb%?N;QyjAhp+ z;csJ$%mZ^CCA(3tTd4f-jZ}gDz5NpX-(wAbLw+si8aFi5OW1)a@7~I=cCBq>hvc_X z+oitfk&-2L<+a_q9pGr%yl zTGAHMr6TuROZG$LK)}mdcuMHsPuUNbwc=78kWr4r_~OkfPq@F-8h$D?pbISk9d zH|S$J&Afj@heIreX;pEmry%Bn3X_RVam4Zz$~Lx3E3kS zh)_$Knt+zU{=#{vK*fb(T@J^oysu}SuZjvn*9JWtHjb7J3ZR-jCQ@bltgFJ%)IoToHs3KvBLL}(YGJlO`A znPEp^`p@H@s_jrDQ7~*q=Bx3g|Qw$o?fw;mQ=y9^!Ob9DUJ0O2{B@w1g+%T)l#BY;nS@fzfharvvv ztShDII2;_bnHPmIi|qjJSpl>=Kr!c+KuL~wEw7Mcv*(Ceujvh0FY~mH{jXU%Jgsd| z$_OSsD4&iSZ=u7Q0TgO+mxW+0B9yK4nNvF;Xx?IMxX$^in-}9iugO^X>Pp8e+$lW$ zqCnH?@L&+cOHW|tw&v48xIT#35L07%W|E6PpS0h33b!1UeCGk8;V|&9WrX`$!iA_{ zRuv)H7^OztTaVI;`i@DPCNboITK!qyJp@3RQjo=d>k-`d7w#e*v-E%=q%yA#iXeT; z#Jg8isE!n6PFdrc#di8nIApt^;Jq^6K`S$ofeAMXTiPPPg;^HvUjMtPciNm_x%>L% zHh6L$6U~7{th#VmAo{nA(e-H>H@M#F<6#r<2vWd^q@we*>eHo%zk)v=X7IRH5`@BF z_WNUB5Fd$yhi=qys1qzc2F|5XP%j?9Fpi3mlWDDhPo0eMs@r_+x>$-;2J zh!%}v%)@8AS+)7}yFaqBOavg+GiEfK|RtLwCZ*+zHkZ|hW@{(;p zVbgyq|9Fh6Hx^C+9pDd9Y%aRvJ?w07gRMy9bCDv`e>#u4k9la#jZq($0|pML5qO6> zF`}wO`R?X@k5F?#htBg{JMeB*si#PnPhxmTJ8TV91f1|>*N3lsx)T}?T#HE+wTXlM zsU-&_QeI%%3H zN+_Tkvl%;0HZ&4OH)9b{ks}J9784%bzVpOIa1xsf)#$xD4~I+ij%ZGe+;gDb9xIQ* z2=I(K5R@+}Y>|Obi>bEfW%(#!rz3_JGNdjgKU?s;G;;-yTZwWB5bAVDb`^!-GnXjv z6eu`U(>N|k-u`ZoC0-Sbuc+H^5B@*VanI@l_NRo(^+q7*8s#|WApRacq0&&ZxazsC2 z!2l=cP{4X=)CMeb4s`zE0IfgL`NihCBM4ctNf+S#D@GbHG$s=V!|92sxVtZ3e?&seZ}YxCJL>fsZf&%B zoO|?&g_-;Fhn4>crR(y3s23!mZuq~i8HQ4@nk<1UBF;y`^PKs4U${ynmomL+D-B+6fy<{PtQJ@bhQozlgySt)E0=1;?^~qK zYhj)!1(ao0aDYsNs++7VYHeWywS(U!D9id?&Z4`In|GQFw@);yMe+23Le+4=CAH353^>}i# z@$>^RDgt>Bwq9Ty7Lp>1jGN_qO3m=(2;gGkhwKnZ%l;nUfHs2IUp6B;@fina_IyWdFIWUD~FCHgwoNd67=ip zFmHi+QZzv453R%=MF(hC%D+(xz&ZvV6=9IO2Z=i+C%%>I3&?>iyD(7Dm3KkqA1Agd z4g2X7vMI`f&vn5dfc4)8M?Y&%T;lRm)8(n@>#{eZx<)$H-Jpc2(k5Nw2Ppr z%%qJ&>E!py;+49S=~f<+W|av~fx1hwli8DGUY)}VZvwx7V39d+_?`iU`OFtes8lKj zbhHDCN`YKs{qgdXhynS+>?W~sU&aB43pT)VI#DGA^7A(XYxw*l=S{p1J#< zY6JSe5jZ#|&)(0ree0v9)AWp%Qm76D*ojE z{AD)?X%(#+BB5iuT_h ztOy@`%|LS=bP5s$rEg%UnvvNcUdKJQE=3T3LWS)AI=BSohQZ%0X0^~1VV{3*jOK{h zjypb9e2tfDv&ouK52yJSgQOv1Cr`AUsdkdLcE@q)cQHL3$LaghzRb+k@&aTpH8tE7 z!hPBn?Nn!e)%AVEDeXNCTIg`B@{LZKAh?19c=0lH1sO6K|w5kO@ZDvv>boSwZ3navTPMJ&rY&=;_Vf;Wc9=|pcT_<2le40-f( z`X%&~04~Bp!GS^WHv!zZYK#h_oAfa#XF*x12wJ5PYqycIUX<8cz_6Z?2=E7GFTqtu z#L41sX$?gm>#vka^<0&1=meZu!9l~~VvkU#5dXU;A3C8kFdWB_j=eFCIqC_tblEjZ zvfUvnG(}pO*Ih-G*2_~1Z~T0+%sM3L(U)n3fy0t_8a4eUGhP>c}{NG^Ta}XRZZ)vnzYLY_2q0oWXPk zBy9T;Zm)}`CVxO8sR3qA6?e5lz||seg**`WRJ?s@ba_ms;yR7?e`x&b%B}(zEdqij zOD?0Mv#co-5PyBd{&Z4%?=??Ip`wE1*97~ZXW0q;>V5r?Ydjsa7jF z9{_H`2y-{Ta#T_0CfT;%=5Ut*o`D6207RISo76&g9N8WZ-%5q?9$w_y-8(21M$>TF z!|*#?-C@OJKNq%B$5f5^LzZ29r`g2uzRzgqX$nta)Hj4XiISS^qNxL+Dvb>wi;m0e zr|y{3g;B$^>ASnD6$j+r?dsRELj$LQm`Br!}`u)+EBL zKvylRowdifDeJvS%vATd=(jQudvi?vDt2x|IVK<<8{rm2O1OXy@MP4|z-c`tu6 z$SQ)nZNybK#~%~aO53{U#12J%aO-f8=Jr)@jl^kDoK`qP%X?*7LB47;q@ZSg*Q`Gw z;(8T0h|1#CWpcmfoowIW9)JT|>Ar&8;k8%MkQb*~c3v;I&Y*#XvP=%ntAcg?ZNsr( z>30v+?GCrv7i;D}1Y35F_}pxlLDlD_`80*QaLWTh(fcZtYFr(0Ohs{53=q9pw}{-4 zYHK45WV{efJfe^~_6)@DZPj$?)m~7Udw7AvZu8d5Y9Al^c=q9#;I}6zU#on4$?EFq zS===yVC$nAmrap7#D$z3aOm+Ast!1}?Rt3~RP!0)s=3*rGA4vW{Ehb+gVNldYI1^l zTln8aW?hS0QIp$U9Wkp^=P(ZRoVAtg!@DXlCc;DB_3CtF7GIR8t;Izih1Go!lKhO1 z6pM->ZI7b6tP{c7hMCcd<7;+&JSY#K5%sq6%)?@(hPtvrrlj)JLaGtT=v*k{O6K9_ zZsZDidnoy1#m=dN+=Rp>@L{J>_d&y+1BSd-8Ee8y$*7aF2(-GkTz&i>n`3(cipafX z>3#pZ6SE@-QfB@@nk=qdC*oc(D{5VsDo&ytuscR>_#H;|7|tC?$jzep!imYyeJfZU z!$=|OD930&5k3=Jmi7(Pm?zAECs^G@_*#sj5H0CBW8FV@lQm%y2Z@yZ{*mb2Yq4F7 zT6&|r>Bz1IO*z4@Zf7A(^z5t1yngjfp};4VVNOW?^2zHP2!Fa~ir+t|ad>Te{H%Vr zm6RTzEhHFh$@XYE&@&nQ%<#;Vf_8dp)?)O%V~4rAJI~paH`NRA-Be@71)6kd#H42# zUdvfUUyRpcXRqbBP|!YM_KhS;qOz>r5g8WELdK$y7NBVjV6_-@?4FDshwN_YiY8j5 z<}du#Y3YjoH@IXrAE<9?8ivv_n@ZS0@0x2lD`M!$9sFB~Em4HYpPfL__KF)y9_jVp z>(v|7TT?X3yYySouY>u5%=uox(W=(l(_>2)yT`Mr2zFP;C;a**@T_2qXM!4?YLI64mm03r>=>P{Uc^ir3XpXA>6hJ0lN40b zf??TIJZUC&jQuOZ&{1|-?fjs?jnNu%sn?sOBi|UNJ(+14CRX1g3f{%-XQk7_ibq*Z zATpkR+xuzn&7X1BG0t{ z*ko9_ReNXZ-f^!6AMXvPlkBzi3Bt4zp9jL$a)uM=4gdDueXTv2$;QXYIN`065C>O%CTj^O&N!xuVUUrBRVT@ATB++j__uKcv zo^N8{VsNPf=Id0%3(Y-(WL?k@w45&&yvY#~JuE!rjfhwPu1T8=q5R=Jh zSIWPuM{LCj8$X-1+UbSV6)-M5h+ZeHed^-jaG1$v`Y=Uh^ilM8y77NC0swS}a`(N3 zqT9YRSq@=ONCT45=}b|(_aN0g2NHo=pQ>J$D(x-<*>VVvx!O0o#^2;;8dS=HK*iaj zw;)aJv4h+bJEs;8tHm^S5@nCm8WDXn;*0&{09P)wo=;{OT3K#n2v#ow=ha3xpGG_` ziv>{Qq(@#)^cUHCegMbi9>jEic1V4+pKnWUx5(6t>R}s9{b0eoBF8Y_-D4TJA*1we zL@JppdEP5A5?a6=rn)wBTwHkURa(};Vg%sqN!Ss(wAk%5b5UE+2ap%MSCWE?6ZqwB6dxt6qG^xmqCVJb~wbbkw@UizG|0ak=>0wvXAEf=C zju&TRf{qtR$wpkS5H>Dh7$eo|YIE`h399rk?>71VASwGNiRBMt?1s8#e*B=OnGC-o zMLZ?RkoX#AtK}dO=3&us0Cg2Mkl^%0EXK9I_?Ek13P*r45OZzzCou`eZ#=d?@NLss zC)1x}QVp7Y%UCi?XR9W?;Vb2f77WYrDO2s%ZC`+}0AW=C)UYt6;D!^y!!8Fmlmj#B z`1Li~o1%f?$TtW6TY93&DwQ_}#NxE4FCppEpmS-!!&PNuF^zbiD6VAaT04S$l?RgY zdlB}xs4tTd%W&O5P;uY*Au&gd_$YCgddaxo_*OGMTUQ5S?9uC*%Usm`O;;UI$w4hL zVrew7MA&{rUA~^riZn*PwxV8;kZJQJLio82SG3Qpm)OLbSEZCBzFEnxN)idNL>+pg> zH+yj_K^i44bac0HePs?-b~;jDN@X?HDpR2$VF0$bERxJBdDP?m#D!#u=bTCOqh(#o>qIsWTQ-;2c>%ttWUAT>ULLyDW1 zatZl|j7}Py${f%}0Nv;qeS!j&&BZvH!@Uxo{mrXHhFM2 zvN(%x4}6f2>AF9P89dNJcnvQLpMg47Y{ydH*CSKdEgj(}`~hwK8V?J9{zP|V6_HiR z9n9~mgQ}kD3SnO1Xx!m6^tLzIAK9R7%+fvug!MJvhMI@BtakF)^`r96+BJj7&YHko zIpV2imItzleBZt`JDxlbtZ0%HGrT8w8yaC3A|BDe8rQLUfG3KVt#>S*DwTd1Wk+9{ z<4^d5<6LxwmNWTcbcMDqcZ>S5RK-=YbS*(D!0n%{VI6qmxo7gw=uf~Hs``+)<$3IRNB)_%44*{(np25 z#Ijdv>MCjria>xtg;u54S~E>?(7|faw0jluwOl^zqxZt`cXV#k6((#JhzzseU+0s; zYy{P3L2;KA7%rT-wkJ(tw(WuzO|Kk_oes6;%M;r4^Kjl(+Aa# zeKtXU(=olr)^XNF4*?}1X^2TIzLzmw2^jt24wwnq!`pF2M*B8>n_7(0w z6O$MI8;47@8T)?iUj#!mkHsu?D#}b1ayRxrwUBNi7w2!b}Awh>s(1X~4!(+L2_WhxZbqPzS!u#erl5 zXh8+0o_$|l*cbdcK=>asmHwBB zYW|wo^q(Tx`9JSo`1#rdTjqbZRBH2nhUofR4>jvW~_R_qN%F-{YM>Uy7CqpyY@O)L}s|MGbyg84v~|y)Cp88E_rB zcY&_Jmq(x~XZrmlh;0QaKIU1@J#gj=NB72vj8`syGRnbs~T zN6(#ubixwg_hpz;5f1(^s)r2n`$yBiOww2HK#0uMk_H=P+Pp6gzpmQ&>Bm&?3%W?{ zTXAs{HuEb9bI2OX*jyNR;VO6y$>^oKWjdug!(q*fQz%(-8)&6Bk|91i5w;hpII&TL zyXdSICLF{fnT`OKv2NSU?3otPx$&Pval3;8~WGA|4_S!EG|a@;QJ4oO{O;QVuiril!${PQ2IeHPaES{+}t zwbE6dACQE+iX6a3!U4^H#>vf#$)TCau*eyQLFJkUIDmBj=#j&0z&!giwvgITK!c46 z?SXEZPe~}~Z1kDl2+Zw;f^CP;562D=I?TQ3WE=4K^4gYcl)sN zyw0R63`-1$VDn6z-uppuN;>rx>1iGIacf+aerCy)3P4bS^52EGik~SSmXu{*&(dI6 z=pLJwYvHF4!mvXpoS{qs#rDb0<4Ki&0PkX+95YrPVGHRx`APaKJ6E5D_+{KWuQxde za<=!1kDLk2fsJE?5wHNG+;3z@|BGU{m8fu?S;~O7-xX-Awnoi4Keca8gGcjy#u?XI z<}ylQvOTV=m8^6_pJM|AQ`RQ0V$O^n5UbGAbO{4XaMh1xh~;ay9Q(<_0PQu(M_f>B z<`5u0nuL5#z3HcO2w_qc+WsKW754dHLdi-Hp*#EgICXbThe;)5n~CGtswK z3f!!#f>K8?hh*xmv+rZhoHoeGDbA9eU*%uT7JXX5p0A;^qVoBdoc%aMhQcN1%##vxelM90n~LcTcYS{*J3m6>lt9S%?L0dTY0}am#Z8V2%?cNA z-XR)!xQ6&6Cjt5NCQ#;uSf(8uce-~0dnO?^~(P+^| zpG?)tLznSfgA<@k8GI>ullCKsaQl^W2(x(Pj`$>|L9hxVSAK`QibY6@Z2KH$fq(F@ z3udD7J|Xk=R0(LBi#~vI%{u!?d;+c3V{E0%JlDJ#1l1`^5Vkg!v7Ei_6(Z&7LXc*v z9hc7&ZvEQJIk>d_Ho88hf1wRX=PtuUg`tcs#dP!|f zj&=*}mjfPZ_|hC>{;tXSsHETqj;vx0!x%^{$wTU|;I6%CqY@~)Y(NCEC0ox>7uD7| zU9MDe@6w>GbjBAH#IE|`MPR&8} zWAT>!Pw!FW2^r{TS+nkrdqmpLRv#;WvVX8^)Ujteey)WgMyP?+vwSm%i6R8$@^B}F z$g#(-VJ)`-L_E8iL{TEt@P|q8l>_c(zdj!GRXnWp_PvKzI z^u7{cNWTk;O$fZ>I_lFLD1*VDyqjyEi9h?Dn|TW735R1!cJ;DO%?LWV$%d!_Q#kMT z+G`9gp$+<3SF(KiQHaa+bYN-aW3|e5p?jo}=zBUB+Zlm}1^F9_;P#{fU~`Ja0~pjQ zCwk~*y#dOp$!j1`-~*}eM6f(ViE#!XgzUxWOB2W@S3fX3ry?0&0DBqll3o3=EDL27 z!o)z~RN;yz>SAx4Z(q%7P)|?IBY6YTZ=BwJE@)7y7)i^?_8kN|sDUJzt1e z@_@u{Gwac#)F%kjV|w=pTnjufrk0SRSLQb8Frq)1yu;9b*CvZ-dmgkjj+8HxRfbcT z`W*)aN}5Op8tQ$!o$R?E)buarG#*~x+NVK$?+fz6%+VCRLn+zzLOl1`j$ULnsBaVf zjM#T!0BLthpuocJL)R3QzlsVJ9Z9;~iyGKb_-ijqwAOoNxvp3n=z-|7Ow z$QV>q8MK`*@7@tFelm7tr4({!?cWm_av?*+{+<1QzUW=P$imw;#Scch{S3e;_*v3v z2fj)S8dp0g$~X%Mju?j7SErsOHJ*r1Xl%l!L8MSi?Yg0STcCDMgV%V;@mYa>{&)E+ zHIs=7CoFd_#yyL7h@b(;lRms}Kyg6MrztRPsz`lv#)#MJOM`9^6V1Tmb$K1{<(^T0 zfRg0&R!_aVf-mDrRO@vs6^q8a>I~LXybKipF@cSlkOiPo_PX$#f$B8P$kF}cdM`+o$Dsg`RDdp z^s3O5&Ln4ytIsNZmGQADqRas2WnZeduDZ7yLm(#4 zRI^ZoA#Lcz{oSy4_u_8DdsAMTr-jM)KA$TzdaP(j$K2Q`MwfaKHkIz6(Qd=gQ_eXf zJh#Hk_jgkn$yEx8;TVlv*zGUp4{8#HxfPL3dfbX?H%WPuwL69u9YC#|@h~QkPtWi% zp(k-v=epuDIpE-Rq0rgmPHPU_G#YZ zYACZM$90>oiZUcKZ%6;8>~)tz)m4RT@9m%+zFdD5Hp?n)V}el9O~XokLfAmG;6zie zk=|YN><`waVqll{AIWgqyXl;(;*R+m)vEPc$Ewt4(^H1^ah9o*5IMlq*(q*HUc*H= zAu3+^u~IGgt_8`rw}V+@!FkNquTno1R!hAqYh~S3wJ;aMSw3soO+Mb~o~?m1IeSa< zYd^Q)5Bve%9-&8&ZtVew#2$L&V+|7zvR=GrPFUU@IO(*p9o}lf>qRF_$uU9ZruRScJuvuq3JPI|#Rr0(q_$&Tb%C}E zyRY3u8IaLkR>2A?6|*%zf>-2&;v;|5?RRhr8G1J^eVK2 zI=b1Rqw)!*@bjJw!aXL|yU_NWf|JnGWBAP_Pk5D=b%TxoWd2=&nfkGf`6O3gKzeVVyMQxvl}};$H3n^#?-ZOzbk_jy29?b%oXstcWwle>I#e;*3(f+ z3M1+4u?el;@jk&*(eB=A*UEgy@|6{5;oha=!oA8$%Vw5lwA&mu(JF(zAQEEsu26PN z>0jf2`AgoLA@#%jkn-e{DR*C`Mo6)_fTG1yqJHljunbSeqg0ksTy^~U`Q5Ms0#Gx* z;2dQoQK^{*${ZuZD`yWIS_vAl^L3~9YN@5&OPtar#wAYGoK*eUP$cD?id!s6GfUHI zF}h99FaN@@NT1}Z4N^0_jv5yprvw-qYp;!tKWD}+vqd%a1c_cYn=8~l2dbtbBOf#f z4S&@5hUHx-f>>YEwayD}ihk-giGimVis>Iag446O+loNpV88WgUIZmy`$bk>o*>Jr=|u^fn~LgPEhwrKmb_U zJr}A6TDWSd`r0^DSU8fSK&{|*uvLaz>tjobvf&Lb(BofdH6>oV;Wyi!vIMBOxKM(x zYvu`-RsuMsbXFXXs`jbtKPcs^+G{y(3N7GP&>)AK-$BGs`a|fnD^y((&^DxS9^|w? zT&=*Dszq8SU3AeYt@>zMN}{9=aUfCblyPd1@5&P4&>#T4Y~wejYSE#&~v@ zgEXaJS4yTF4ZUn(=aCOAUXW7tyzE@FhWNXJG1pOm;8%sTv}(Zh$9g~niqztVq=Ah? zJQr+QA~??I9TGePXhBi3)uC&F3aH=eFuDLEtfEdo*kyR0` zca8j1*NniKG+Ku!a)%|X*b9d9?G0Dk+B;s9I9KiW|Ml|JT!+N*QAMKn5OcS*Y4)3=+ z*&66z2{aUM^w9QLyEYr2E81I&4a)4dR(@U)Pn&}%%zBxrQPiiJlu@i`H7*)X=bccLT)`EVmEfJedt<;L=ktTPOh1Ivkl2PN6Afg0j1R0>>rpf%k~@7{B@< zRqOsl{XzPC>IQDYc`dab5wYL6H8gBO;`zV1&(;vu!Xbu11(U=gezq_TaVP0_k2uc8 zLlFr6E5J-F@^f@_tp#WkARS9%h+WHy$0|+?G#o0p*=0vK*{O#yWJcq;8&APh#*us$ z4;rr}lE0mQ=l$>WdS0aVw0wk$?i8pyvRNR9k%q=c;%L2|#}KHi#uV*OyRP6cuntq< zhZ$v+8leq`h`%2_Y^Aie$d$`YbcA<8p(!rLKLtxE>jC};DYC$xr?3dHa3-UskKkOh2v%NrZ(5h+a5Ic!iyq+|sI9X2 z0w7no7}>SuEQ2>CEZHZkTmAie)i6|X<}#%Lp0jF)#hD5WW%t7E`N8FB=d@`4_oT*8 z4l$^5iZl1_EuGQhaz5AlLPKrmwP3Zr3x{!_Ab)4$Epc@~^JINW#==siY$u zsYiLrKmM`T{=2l${ufAfz?8Z!lV*QNMNB8_Lr9bGcg<=Kpwj2Ad9R?ua(V&t(^g=F z**Ks|A{jibE&9OwlrqAf7j{A`Z5MssBE4S}lsz_p=cV_LgVBaJ`{z?V2O3Jj>gTZb zan_lqj*G~}MZ`>~)mwG&(>+{8mqCrvaJ=$Bx7jZ?epSaUpJ&TB+%*7UOsYZqJh-T& zb>0h(Ml@9k(`3FPj{yiI8=)=Jo+!>RN4mG){B{r(sH`U-1l-bpcXdVM8{spFdA0VH zhwfr>Li`N~B`F2&YVNhIg0(HnVZNOGLAUaVrs%VywqLw6YXn@RzTw|b3O|azPA<8D zkX6j?bQ&TRC>Eji(*{n!UZm#R;bNqMq3Tp8Bkzq;7wNRFWp-$|8o?`3sR6Wh`)8M@ z$5B*d+I|iu)rco7b}<$)o9XK9vN5@j&>5)?q#{v}hwSb|7TTV)j7m351PI zM|V?qEWWKCcA)nfsH=PK;tLTTQi8IaD~UhgzO}|T#KouBqpXv%i)k?mmL}V92dTYy z+{3?gzmM|O*V|5X{zf;);LQ9=O?H0yy>=e0W;;drf;n!uT1=NHs$`Sgw!tns=XoZM zBU+Mkx&tMvT3_C065Ax{kX3>WF5KawF1kD(Hh$|p%V?PW&TjGNdKdB#C@`QQML=x0rNE~$lY?a z_+j{k=H60wMrd}ebRk5UIvB=p`@;9|06Bk8O^z?~vfdf~O;S@|FAAO@*$dh+NZ}_4 zV*DH4=u;zCY8Lr7ga#a}6!FRwbkBu%piR_345>LHT+hZs1CA17__Z!UKi~Qt6b*`D z2el6?6V%a$oWDzT<-I?sYiSa%0H$v9a_`lf7pP8{Ybfi6g^iBx*eDROI8^YH{k_*m zXo*9c>XVs4n$S;skofakV7m3zY)$2CNf=gAQm@%{1{A=KP17+pWhO3wPZ;+ z+xZ#qBdvqCbbgyAL`Y1m)XwrZ{f^#o6f&!-`46@z4SeraS8};>r$x4dsg{jD_wVfh z^bIrZ5pDNS;W?qMmN?=8j8v4pVN%eqD+S?{k5r$5&91>yEAqDjZ{~~>{+uy3%}lzl z9eiN4WXiivS`l)};X=_Is=wiPd4Fgk(%!j?4eN;B9C@i<5R?;?a%8D~EpOykYQBjUJmf zsh%HdXUP7O-3YnwHB1pTJ;En(Vv8=yOywaf#DSR+IQQut0%#Hc55y{Wr1l*Hc+3P{u=;rh) z7hx^5Gv{9JkOs_)_M24!xtaqxboG+@ z;Q}4a@TxX)`QgPi8ytZ>7)R~MTK02BlZN+AE51U3Jg6W2C|Awz;#Zk*~DkZz6A*7Q z+P_h?bgLL)eMu0=Tt=fIJ}S1x!AarT?*g~?Y>ZnwaUOTsBD&&pxZ3jY&);`xfo>A4~J>c2(WuW*?-e%^ACT8V&F%iGAj8dI07`@-ujY; z!TJi3uFzHw!3B+O+DzHaqS5#@~OO0WK#D?T~I8IYWY|LAtCz zffbhWUExp)u!1a~-VF@5mr_N$v37=4h_aQX&nJ;4?qx>19?qYmQH!iY&etI6r6Pb_{Xp?H03!}rym`Md; z3tXR=>s2POzHOKFLhUgEm-Wz6!fyc_wH3-sbI^T+o(-1fvqPKT*A2?P2vATJqLtqR z!zEY`{RlW-6JTF^fpm_C7YtPsgEhmq9Ynws#6ppCZY9bkEVQHy&#wLP)B~39A79Hl zR^Q+yio`P=u5?ixse)_0{rXfSj&V*e9c&#USk1FmZwz?x`pg4E@lp=ur2HWIR1w1gr)H3Up%}wVVT?ZDde| zJ#c*blGCPm$ej4;H$ocE=^Bu&R=J0pZIN+2Wjl2D9YDFl*bsnEU7Y1E$M@e#`*%@O zHkg{jdcf`dWhG_%pVQk6rC>Z0ORn3NzZ7zjT!K==r(>X_lgx?_DLm^O;%&HvgFf?-EMZvlo+ z1YZ1iJ3G|`x_@`x|2o$Jc9CB;=P!fi*#66< zhW!tEfB(X~ghkyAMg4x(9<^Ddf|4W+Om%o(ou`1mE-`_;6wlK2ZRRrpv z5z()-ltCkBUIh9aDyqXG+@RiTA1+vW0d)G^($Ck`!L5%~L&ik&ce||Fpz+_g(m!x! zm#r8%eqE+WI{G&m?MT5n@F2IwbQNnP#X!hbu9Ms1#!&tEVge#3qjDGP>` zAPIvc;nqk-P2paafZ6DvG3FD_Llt6<&f*+UMw3}U9sO>K!&^bJJ>p?k^MA2tmmx5Y z&aq)K6h5>xAfJVQ^CcM55{AJ#oEHGREkhBM;sj_eXmp(W0exMXxULAwC$k`7EeBw` z2#`CbZ}+)qveLy+Hogk#v~Zl}5vMBk9mdn3vwFm}O2faZb>yU}n)I0%4%?1`oAzn% z_!D`w-+!rd1*NaW)H>$38i^nT$_jr0`{Bt!0B-8TUyefLns9`S)#RDzz$&s@Ge!EB6t)juJcflkJP`x09YlcEXF>t=GnIIAfGi zaqKAODp-8}-~e@-|4^{(-gGe-L_RE2e94H#I1yceXguv`UswoTIVWGx#fy%04A7&X z0V4vT^0u8vh&lXA5XdItzMU{?vq8Gm3Gs&oXXEzpYWD~iZBrfQ4~=$~QXzI}J)e#^ z+d<=6wGDymWP^5-olvRx>`Y9c1JFz5u7JyvK_OV~3+`7@6kt%XMVUfdF3UEr?d+}Z zC%VCar(g(-?`iEvuS=&|lGwDvW7f&kO}`j6r6;G1e*P2`Bn0)dvgW|btsl|+=bo1_ zn7{WLGJwIHj6p)^-}VC6fA=SUd)Q^YZX2J&Qs4|U$Ww2P7W8d4>wKb^>{Xg<@x^AQ zwT@kugz|P{oIuS=WWbzW5LtFV$5ycm@MVMr+CfO74+|%c}ml`?)iOq2|+#JN!K`Bb!z|a2Zr1 zt$rT?IkmKs8p%mOV_gy0uDlg{y?n24g_(4xFafKotPQ{(lXP~tqF09%r^<`V8EDJG zti#?BRLOXOM0BLTc*hmv_6Q7HuxuXmlTyy<#lS5e#Wo0|a?@g2>8E4nbay&D5Xkp# z3To!C^KRyFEFKYl&}^8i^ze(XAkqM|#J>g-(R0L#Tf^)(PATjviZ24JCobFLY0U^- zB{#l;(N`ywXP9U=QH@p#bt5 z%vkpC%g|PJTqah3-(HUKe7=A3BSQDaKEG4G8{0x+@v1KmdTc)5`8FtrG;C0RWazx>}@$iD~ zW~8_(QDK!}Nsr{7v8#9PQMvXwwpV>qanuP3(L=F$_lAYdn-!lw@4Nd~@+^tf)}4OM zg1!Y)8}GaPft}=Yfjyx+)o1}nu5V6FyC1HLceP&+>dDf(V?jA+107;&1J$7ijEix$ zOtBDMj|jDxC(ldhVqh;eIIF(1AFjjnMY2ov9K-{-Lc7=Ma$;6Q9OR+J&ZRfcuE6Eh zcA5e=f^mpY_A?md+2%xu;h&jG+Uu9fDIN^t?G_f`qPgPNphl0z^;LY1pX}>ehP~%P zS4lO7hCdg0fTB_B*y->ISliI?q7;mpMNYFR1!PkEUjOp_Zu?^~J?uL2La7SP$J4gb zc^Q$@%3HP;Ri%cL_Dx|r-gFI3i!z-MaPsgW`JcS5R%)XWraPX&_$ZV_Fiam3Z@qP?4Q-aD!CHa2T~ktG4odcauc`Sd59c8umasO53ose0)h2QVT2uVALyw_*-K-0~S&g=h8vD{%(^w2KCnZpq6)9Ls8<@u#?w>qyVDoIw5`OpC{7%&Q=+vHZB3QF^el4-R zzE?@zBg){Noxc2&kx46{f0?l!(jcI{t5i)|SYllA^4*#(9V78Rfasj zMV)X+J$_i*%Q3BErp-PrwEA<0TagI%0yX}fQW&RPO5R_+P?O^Tp!OqU_n>d>Si>=cdkFt3KXXNo+{590`` z+gDfA9;BtJk%korTawbWJ8Y8vFf=AjEeU0`2L4j4(f1@}pZq>f?!sSVzkp?tytqJT zku?PQ4UN%uaAizefhCtS`~FzRJ|);jJc`PTIRzKig}Za*&j>yYj!&!AQuANKC*SLo zXs>O0Fca;QV8Ivu+a^5-VX`BNY0h>>T8)UW6Qx@)p(9(HUD9qn`8PG(`l?g=B!wIN zt6n>M`V|6e!!s-J-i!Zb&KHcz#NOl5wSy| zfue%^+4G34iT9$F1Zx6|vFxl(C-n%)vMekbXlR8w&WOAbdt!BBT=NF0*C%MpP~V7u zy~Ec9u6s<*$_p2~CSHQ85(+bgTC*RRs?INiW2v#tYM`~U!RgyYkY+PdY!I%l znIheG>TwA((>c@7mStHs$Ye>->!IRCt45oLoiIizwbAW{madSOFS75edp09r=m#3y zUXL-Rcy_Bn-Cwz7r}ZGYl2aR4BRVT)_s$2WWYiSZr*LUA5t-o zszWdJ1!gQR4G5_SV_?q6GV*3f=zGkghJ6b}dvHB=XF{fXI7)v#{C@(VbP7Xfa&0KZ zfJj%RPu&loKu)Z2gWdK0EZdLJe>5S#gGzTublI5QnB_Q~5B^}KrO7CNk$e`eB_43j zs*$Q^47r3lqc5BNZA4~U28RA1)R-y`NufZuWmcP+%4_QoG!WeLn8%g!WhDNT{KG8T z1)SOitauhs$d=&LR_3MUeE?8Lpz;YT=CrwS*voQ2h#Y}~ED-a~AFhKCYCX*T;5`R2 z^s*CJ7|^Pdd0&Hf!tlm6f_q~38_0q-O@GC|Gqga<%kuipXUGL6g_}ahVaIa4=o}a| zKy?xC>p~FjQW(l}MXY$yi&mH>hM^7-JWthqQZeIUm|5plFGj_prdvjSAbOBD`48hP zhmJ$^j;m-1HA^}V!?T~P>fiw~cwm>-IN&|ADY>?gn)y6UnlS2~x$w8O__Q9ts1FPd zslR^*HT~#hxW+D0GqmECH}+|-e}e1wQt#@r6>B3)?`7qGVF*AGv`1#o4^XCA@wtew zwuMb$FXe(c!&W+qlQ6&%N_v)iDsz>*P6TpV44e$Z8mpnPw}9jJf|mo?X!}$AhG%n@ zQg!{(jP6D8V)X=(Kp_49MHzxnXcOAO!mc_zWp;;UoZ-h&Sw@Wm)nRU==~%N$9pFMn z-clqmwY=|C)Gx4H;MBt=*jb6OW0J*stWqj&Jj zkti~ES^j2dH19+=YO~8)yprO%&#EZme3HtH{#$$R85UK#t&5`4bfcoZunm|n5F{f} zl%QfDqeT)BPzeeMC`rknsBLIKs3HeZ3M`@mRY*o{2?YX@Gb$8F5+q8#W0cyf_d4r7 z_s70xpL6fo{8YlMIluXRW4z-H4tiIPJ4`1don`rT;{LuKagmdb?_`soj>j(7xNa&w|3+Gn< z-5vg)V%PT^s_H*BQI_zP|AfK*--FP9;BEi&g_+;)7f$zn+=Krl{QuwA{`VW%$MFdV zSzxZMvUp7mGaH%0E`aFuk>6e2W?hNixwPfwkhKus|ZCqb05uVOKBb#DKr)8*uCcaQdl+VV*}kdu6JzPfuRrqHFYO0Fk0 z!pUmg#H@UccXgNTXyLTcsC$iDY}WL(MQ>nhJLGo%32Ja-91%K#zJxisr)KA}Z{TaV zdKPze02rE$)O64?ycOWy%H@TcWu{aVwM&;2lsCl0#9(}$03L}b@-bGQw?E;X^nEE= zK?h@)y5q45OX9qUpD+4YFb9J@rGMBK_AvTk_%8st0o-)76|sjsv8k?Ad8JG%xbiQK zN?(@*W637TwZsRrxM8Er@8Glv;|qAJ8z{@b)ISKp_^NF z==Z`oYlB}D*O#Oy^;XYH0`P39k9<%l%77ntVLA*P4|qBE#{T5VlS~V`f1KR@t|#m> z2rbWGr&ftZb;`$Xo-Uon*(U~!*{Uu>!!9>i#NY7+{1Bg^TUpx0E&JGAnZ+JPS670M zcUMzg#qPVh78Rq7kR;5sP#Xu(Bij?)>}(5f@Zl6(-_VjY4josKelCWY4n@4=FYd;k z3v`;dj!0tw<_C6_ z0hC$E3zH$Un~I5LC#ajM!jalVJ8RSZ8H6R{U~8AkrdOBAyZZ*0wW`!9@MO@-8G%RT zAQDjrBxqyQq2&2cg!gZ;EC78&?Kp^rvqLB2SnblGNMNeHS=O!M*VE8~bb+m`hH%}L zLA#ZWYBcQ6JA**2IavlEn1$bJ-kmLVfTEq!XvgMsNnqoHIz_RvB{VW+N|UCZuwFsi z5hessOf^CGIB69o8&3SRHtJ+DK};2f@;U%himgSy&u6VR!^ivBm{FZmO%|S>Yr3&S zx&qcW7)M1PGM&l88e5~WjrD5=nzbP*6AeXEk7T6dpw5V41fVB6Y}tj`rc;7)S?BD71KrTj=#BjbX7?M z=XET@Y*;S9Yhx>7=^Ey$Z7o0r=vupR+6FKy5O+K~jEo5*b+xpX5c&mcE|Q>3{di<^ zK_bR(CGXBaxia5obnsh>gAb1pKC z)|Px0x_IgE1_Lk$!V0bc`OVgvR}j( z*BJ6(0FyEi5o(L%&v?7P16i_2L{p^xqjC#3_%Tftx|ywH8)2A~DYjn&!_lIza;;0l zWk``S>kg@kuOe4bE*p6GNcJa6D}o2W1x$Vz9CCW#m?{QP6=j;g3^@yyou8-Yvb=y` zyWZWzNBc80rCUAEfJKn)uaDCdmPR1Up4!|*BN#SKZOQ3(^0w-b(2AIEO{{7zKZ6&= z%-B70zd$i(_@YCZ(oju*wvy1uJ*HjJRA1=4VN>W-PFbFUeQSpGqNoxWJu%SJ=KjR~ zb}z`eXmV?ux<<>$RA?>xJn;pj-L{1nGSkhQLbkUfMEO~Hnm1>mdI_MGPxY=-W!{WY ztK=PIhRICHg`-9gy8HfqV-VrUFyfGikT7fUutMT+y)pP^ZJmxhaP@-i_d;n*4K%L? z;1A?jvP6!)?kO;8mPQ=qK1~Hrux%#H@S$IC&hI zL*ww^Da_(@DkCn_OV21C<$=r~n#IhSo7@ZM(r!=OH(Pj&)(Z^u1dLm`?P1VMNWe)O zFJ7K6W_oJ6K@W1ac?L2|Xv~^?ek3QOy)Ja{S2x9%5glegHMVhiD2(*~!Ofk8s*SM) zGUtDH{Us^ib2dh-Zz2!NQ))IbARqvUz;_zNzJMLt^w!vNTObwt5W!}ma2R?pu$#$) zih6c%+G@vh*l(N4axGImRv+MPd5&Qos5Q|qDfe)I7kDn0tC-I(?!hbyb(og4DEG9S z9p3&Ll^tjmB{s|J9pLb4H+u)#3o^EKe?4I+P72GLg=0vsj6Y5iIiNi-EUpk<7V`A$Ia#)J8K^FAQd== zT=8XgvT86XTx9l6hW1zZVxLi+1h3e@mh1fMf3 z?FY)BT^gf!d}jAh2l&m}9_BmpeFi)dvt4P>9)xgA3K&kBgQ%zn840D-Oa}Qol@l)Pd(&6T#|-4$Um6 zUF^TLS<_p7M`<8;cP~&%lK01U$1(1<-{X*Jwua2%%xu zP}eZv$=t^e4Y|WmZ=o6^J7bW!+#kN*98ODD;J!E6m3v$4=%mi<%N7InTkpplL zCVefS;Tx>z1K^UYR-_a~v~mdiD@GgapM8_37YsZmlomK1yHTTmR%X~CQ%_~UDak*%QOs2WBO^a9; z-LnIDZrLR8w1fN&S2Yyi%KWO&KGV6iy&3+?M@YT`}hPPI^s!Z{jP>4V1*O7IX4;pQA7zIx7&U zWJ({e3=4LZcZ+7Y6$Li(1JQ*Ml75eScnF5)YWRqQrU%9swzQhLV$9#n27Ka zO=-Z0Vh+H~K2(30rv5hW$(`Jh@K{+;y8u>{Fyaxm)|4U5s94<=96j6GhAusWDy2zZ znzD5qc7uGVvUR;x=OcBqQB8`~-rzWvA^P!O(NlRwtWl`V(FeUKHWCPnpwXE#7K}}J zRe|0C$R^GcZVwgYr`-GUO04nz$Q@I)ZzDQ@-GVrgoc5uYYI?wyw;p^Zo?0 zqMqNJF1TBCS^DTaBzsInE!ZbW>o$k#J-n(tPgZdOf#qe~;DZIlMy{&<6pdP zxMW&#oB6{U?L*|Mfd_hmoK&*=sQ~R>hr`XYA#q&ANFCI7Ro#|WRK6vVt|dsb5Y9I` z=l9bf>{%+*$Jy5^!#!s!&0;Tnwuoc8n-uV`dTuuB5!qxtyDgM zsCM#J%hYC={x;>$edchuuDsY;)BSzybZ&YV9M9GpvX)ms<>@3~V5UQ_K13S-GNqo| z-&LVBaiDU-)3o_f+9`+epJtH7Z@iS?a@xR}8glM%(c$W_9)*gqDhH4DcALsLKDz*L z=T)ZdHcOZ>Xeas$)CCCG+0-RcQUK9@=KUp3MuOep*h;?H8wzHZ``65;$3LQFtEv6Y zvm(gvmyQIgRuOS@sg->t&!d0LjxUkh4j&INYa8N`ua(wlG_aPQ#M+O|xUPDiNMRzc zd8>3oU8q~|BHfjLxHeSA%9=zHH+Ut{X{ptlwc(Z80sgvlf!!2;dkF)_D)*50num4~ zWpquH@+k|7JimnQs)9zg)##uX=W2SmRO8YP&3$|;9(^9q4?lPMkbGUJxSCPK@jm?- zbK>)w(ZhXCGlzBCL^W+ax)F7|Ep4wY-U7eNb+e$>Y|&*T&}eq$t|Y%D$c$!3vEyaXSVfgMXR9z zox0fE^PK=#kO|R3jZQTT<3$4J(t*h!g1#}8SQe*J5ke?^5V7#pHzN!hLhW=%$sJ|| zz_ZGg-X8L-936aaN-%?-mq&v*l}q3)?1(R z^Yi=8`{@p-knWhQ#9roqHa~iR{Ymev>ziQDh{cvQ z=20b@Gd71u+Oh1z<4b#^)+aHF#XI9hlB+cFLASczZ**J7=nY#{u0NvS9MyBg)=UV4 z*W@N^mCe>(p5)touWO~8+&YSTy;m&H#6>f3_@wrcR6dE&LcNXmSfx0VD@IL^Zezd7BSi(q zZjR}@tnEYTB-6IcLiWo%K@)Qei=X&vX0jF7Z}Bt`yDJNFl6pc))V(*uQ{Ag)NBF{f zBn2r&`r1$$=MO5*=qU=3OgTT&WhVKKL|293vrXjnvGs=OLUXYby9~qO%4hyxXK>UU z?MN4pUU&f*LqqX9gT;jM30m@s-6dM4GCMdsRH(#kE;V1CG_7&+6>ZMPD)|w*Vgn-p!Pem=%`dC`0}3X7}z$RvFGF`WJOZkpC0T zSGnM9l$d-Wgs#XgH4Hb0YFUT0pzax1BhR`81*A#lby5ENA#Neeh>N%J6r$%_H>vBz zT%LJQ-r|zsUB8+AmnmnBye`hkfy%rMpIZTkpySwv=~!wWvdPB!!!+2Cq@Nz++Z`V( zC^)6(^Vz^bq6`82y!7FV)1x5M%ka6mPHACmtCU7#ynmZS1{7z*ZLZ%3hlk%=osb{D zL=Z%!x$Tih>M-34y7fzO5fB> zw$w-&Auvb2MsAl-HHi`hxq*Zbs~7aLY$RQbT=mTF$`r90UVfFAnK>g_Int|1zAC-Ej~I{I*+!&4XFgjJ5)) zN6%#~r&`2DPF6WcyiPpGXX3zBS;SV$S*?Ek6B^IXLOp4C^Eib_TCIrQwQ*%L>*V$XC=^Xz8QE6fD_aMr>A1;x{}J9WwXHd^HA zn^yF%JN9(%hX=ZARP$q4w{VuKJlc&GNgAgM_vjtVteL$q4*_!yB3^ETd17+6HASFl zrmj3K$ja*(a*t=x%d_o+pX;y5lr-H!(6W-^jw8RL39Tb@=`?ZIIhj3W!MlF$x0RloU2tWA8}c~ZUpXH z7uZvTu3C5C(d{{jQZLF_oxL9^S0SyL??s)?nnb^@3|iHYf1{&(R@_7LaGH$*l~27r zL$_#PVCM3|*Dq5S^B2EMEXL?~x0>cv1p1@(=r$>|O_Rl*e)#}GHZ+}P7fB$fBnXzH zs7*-wEWly)sXXsC&j!nx+4E-yi8bX{_IJVPIPIi@I_b{c4g9p7Q=J5L5k2EPR5nbh z&*3mkQ7b$l#dAfM{eIH)u~lCzJ(GXYtWlIAd8vjA#wTW**yPlM%~2587^x>|w{0L_IDK@J*v8*0!g(wSgbIyL~n)$oH(GZ1JI0yz`N?3zS4@`;Hf3^4{zr)D(4Iang)) z*cY|yl%)3^RV^9YD|KsBe-S3x=5f~mUpf-AXHCXi3h)%;fj0w`ofJ% ziM$e$o<*!|Y|~3p&y9Unnx+-pQg* zpSN~ghfbF%FyH!gKsCQjOOPW}3omm;03%+5;X_w$GYzPyR!J|utHD@Yo?5|B%8P3i)hnduYk zsw2?;Q$<=H+|FntOzm|NCf&h(k3tjk^9QfMKFIBQObV zM)`kCvYt`jRig(?>p!0&Vijq7F)KGF+ZweMbDp@5iky2a1RBJX1CPeW${jWm6STi= zoR?E%2g~!qxANut)i;L4w!NF;DITIM)G1sJW?+B0{0fsK%J}N3a<}tOJ$@M(W#4q-v>H+;=vee@4GFlfr1OU7#M<-ie2&P8yL5VG{3twJ_JK zcP}?(J_~K}XQqw|5-pYzE~q8*aQK5aV-Z_7XAoV+s#v1gDVlSP-coZJz#c26x7#ih zZcvU;rw{a9lPMF9Pb^X^7b>2oh&L`72b)LB9fZ+Hn_7xkd@!jGmoaht++>d@ky!3i}x6 zu{rOFFGjX9n|)in@VBS>o}~T0&vFlG7xqYVx2_w-_o{dKuN`N6#n!M`UMnR8%Q*de zs$ch*adP`Cd|@Cx)>foGR4h{)aQ(Jwev(aQ?5#~kPxXUUYR`SDjZIQtd1Q9a9D1L7 zMcb(yzZYwiG0A7=j-1pyANF>Qq5DIgcjLsW)&uT}_I-^uzs*X>zkK4q3RxptciQav z5XR~@Zs}1fGaoxrvw3WlK-$Z7Fkx`l*l=0(TtcB(YbdMfoJ0V-JmdOBj|~>((Vqfq zHDBo>bWjO1W@CLbuPLK9``W_K zPsF8snM`_Mc~qRM9jiy0hfScl;8McTD%dm>^;8mMXN@0r-}JYU2pGu??;iD^;rw$T zrK-a98F?{>qF_3EsJ_thtifWTnO($YG_>JpA-n6$=o20aYOu5Oehur1d2eE&Wfcca z{4?E3)vpd;$3FZuTM+6D0)TR0PQNJ0Rf#1_ZRH82xp#p!2lm-DxvxHX*k|h@*UKEY zd38gRHGX6OmQ>bEZgM{Lf$jQn6>6|!*MNw`6jZ`7AR}A^ z%_nRHCgXIq4+86~632FmfWq58|L%B`6~BAOjve)SD_?DX@a?yNJUN-m3$laDozn zxs~qhs?{9yO853!E&?QL>EB+@Z3@6-tOO)SQDc7$ zfm4yvGRhdg}<(3onMq|I;1rA02@Nch;LH`z-(E*IvAM zkqhG8hyJm4XGB;8rUv6zN!9NEMXUE_LxO4u9q?*BMF=(tgOJdiA5(7@@f0vjIEqdS z%+Wti-2nq_|M4obl$-~#eKBI}hu00u%@B+4AQsF225)!?Zokz{izi@C|1M9~<12V$nS#+mMAP0a zXRCfl|1Uv-d^OGE?bH7WD?pQ*1LWAwuCBD5FW*0OiWDiLfe~TfpLj_X7`N7<4R+}t+xVSd3zKHU+`?yn7@}33 zRmNG3@ys^laSnA2_y6m5#AHTrZ8-xy#`Wi!ny+LKsQzm~J)-=UAHEZ)0Jw`Vvwi%c zfa#fo0v$`xK?nnN&bn}=l?J@L5irN(Qu6lX!n3mj1jvMK-=~@^pMG8`n^g+~8Ty)@ zGp~O|soVE06l}X;>jdKyBI<{y)_(t3;!plY7xNq7=vZsF_+y`8nfqIa!q&fW&)EL< zGb{c^)B5jk|L;3<>A!jroLl`r(!YKVgl;^JnNWm|*UaF0ql3@f=c}+l$?zJnwFLQ+ z5Zb=k4wPUGx>$wIw%;1}7_X{(Jt*7kgekV zt@6psCBDrOW<=T=t%={AWUemQ>d4P-lv!;w(5?XvhcD4#+lH6ponRG z={Og3t`b_%)2R1~*yI?oF|L(ai~ydR5vyLs?hs_M;=#s19)(DRuwM?Ce>_7)-P8f7QpFFjcSt$6 zE2+D6pC@}Rek9m?KP7%ywpQ4`iunnv@+R=GD-mk>#2;cKz<7Q-?I%gT4-weUiFwa< zZ$>Q^66}ow6QwZl01s2-vQg=8Ayn14X6-t4(Oe4*rD3|pkJhZs`Y7qaG!6lddM=Yb z;R7TcBX$1D?J6oZK(+FDqhB0BDuWqu>^=mHXW##0_&>2Mzj-rwE%oylh`idUOt%8YusardDo%{R1F10)JZ6 zz{Ew*5Jq3j8pCMLZ1$B8Qo~;U5$H8+o<_b|hAE}Yh+% zWUy7C@ChM;p4s+ZvXnI!6;$CQILV8t z7MWnkn5nzf=I0L>;^0`dzl?~Cbdrv83I4m);1?mqz5i=%XO%2CPS3gYHXu) z{dLi2+p4!e^AX_RX-1zn5Ms=qBsx~6k%cEh38F;_#2(W#MWuJtvg6h5<1ea-swfjuJ6R=7UvesMAMq`xw^c5Sgw(KE{lVQKqe?Inv6f^Xoic& zGsj|oQQ+nftZnY?qHH?m)u++?v8oP0V1Q+>exGG%v=zpX?- zF5_z3E{1n$cFv~bXs5?Pu@JS?nA@Wlk{QTOOQ6@G7DtJH9-S7N#WkBl)lA-g+A_sg zHVQM3jIu*3!iLpLcZtMHC`qzuqSDW$s&0?q(176XB-Xo}RzvQd5M8z+16exMHqAbR z8;6nZG3YsbAL6-R4&rQ1_dG8=0V@%BCR>%JZfUNv*Y~v2cHFazpLg6zoUUggH%nY< zl~bK?3`&V|tP?Zv(j2;%X>br<&?=d6Ue9EmACwg9`Rs*pviwv-Z<%3izX!X{bS3e^4oaC6sbj}W{=DcUsN*2{xdHf{gZ~l<4XF#Rlw`Q zfasp%`AL~OxjmwPB4T#fUoX>)2~645i}@vDcF+P4ntZ-Ep~XUz`7`{HK_xch**iX+V=&BD?aH z3As8Vpi4Y;^3^`Cn7t#(BIpWg=R+(p2ryQZoJY!6ldH;XYX}$D6YGkDoYyPnvlL94 zBjkd{^t4G2+$VrwRj7Y+(6p0}lT+=?Ffszu7KlO3cvknHDdqX_B-_36xvIEm+2gyc z?#%ri=ap&r=+?49w-VtR(RKDiNM=5K5=V~u-f5IL#cnBAB`v+UcfX5wi*wH(vP}}* zVqrnGhs8nTSaNxHYr%*YB1@P%QEmnCUn)dvWJQx7eDY@wtH@wJj8* zkil&@%N+bk(1hW$sGFlo4$qz*P(Ku9%?18O9L=Z*^xD5R#3fNr>nI!h(j*v?LSXWX zgrk*k(oGAzlExe#1)nd;28GGgUb3YZB^WX?J$v5>Dz?(_oU(66?SknGT6M@+RP&v8S1x^c2<>d(Ns4&Fak3T z2Bi0R*;e$rB!1MKR_ZU#9PuIrY4uqJs9s|~;srCAw2@@Oeu3gu;OTZ%!4)jWa-&L4 zi^{YCw(riNIb{WM`i#aWjvojPq#N)|!e;^f@H0_7Mc*#@L8Ui2llW2d86VlxNl&9$ z$jylN5^;kwbD9GQIqvfNWm+&-{M=!A z$Yg?CEr>cJZwJGFOuFgO?%lSKpwFkI_8?|A3T`4hap!E!-|b!X&)1%;tRct$YmF9(xo zBNTrejHRq=2+3xPij|uom`jORCy|=0J1t|dm<5ATUAwr(&2mW(n=cmizLwuogKV-C&n&uY-iG)LAE%)R`2~t8mDTr-4I3k)MQzUGwC_Eg1d9tnGhLe z%)WZXcE>#nPmF6h$pM|yrg(H=RouP3 zv#pDQ>0mwING+Q|AzL1>49_wrsGz;{>%vGlR0Q zzlG=N7fkIn-AA;O2UY?d4`}5B)&>pKqYggSIgwjc*o~QUyEqvDy&a)<2!$n-pcqCw zQAjns$9iGz&_aDEY$J)fiPmO3Km=QPfPD||_B!M1iLw-el!BGT1GaqDQuM%RixnOr zJIXJE!+E0{#&s29s7F`+z-3 zvrk`rnL=TshmaT2QL1=yf9cc9i0n9#9LHE0rJD zf!*`D?a3SzrbAR&7Yi>rx$r@G_B9MIHy^<GPsY*B_&awMRU! zT(VMV4UJyw4t^KL8Ic4nErtt9JUUCylBkfh*ErLkTu|=rl*yYDTl#1q+{Mhyy%&Wa zLKrV+<~a%luc98M_p*6xH!_oZfB;NYtWF!EG0s@{=ybi9KLZhb`*o&zwd2O+vZ&cdgBGI%GA9RGjK!ApM+Sz(_XYG3?w>M z8b`{4Y)~TO7 zHe3Vc^Jt4PCpTM_u2AX`{Hzk?>aVw=+sZ5`wpz`Xr(=69wA8*Pdb-dg$vIiYt+&tW z5mQoeD7`gtVB}A2pkD#?cH5NT^9S&tZ)%T*QyCU|^xu-4ug^W{x6tb=Ie%w|KC&xJ(@a;97PQNU|h8y;OJ81BG#y@@EUOS@fgmP7ekcUJ}j&|o1j{=y(g}tMYG%? zpsWCZ`J9}aSE%Jqt5)@a4D+CrUW~;cqD_C1lED|tbt8cIFi_A?2s5bnB;F6c=+6bi zahLFgsvdM5n7S8sXMsCxy4l*JA#9oQbI4@}+M%*Net)EoFWZQ+R=7p^1z%Bt@zc<| z22LBbExiCVIWcum+f`MDpk}fi+UUhhnv4DUE11&j?SzqgQ{6JX2*p$*&-Ln3cRnei z&pyni(SN#V?Kefp9FY?XVW~&Olmu*-wAhH$9ghZVVC*n;eM&dtVE+dc?t$Oee!KVZ zYP0=nXwx!#sNIC|CziIV)5IJ<>QT!CuMJ^6=an%kT&F?(%v6Hpcjry(Cmz4$C$nI4DI1Mu4B>l$_ zk|SCS!JR|4IOB9+nh1O_fOt5Ni@@|8aaTP>8QSRQW@4I!ZWCn# zVR2=-JAlF@7Rse2}3xM+2Bt*W{am8JiG6obq*F(Hfd)iCy!viD0YtPcUm{) z*O2A@4Hgy4O>)h(A-Xyh+beDznq8L5(0n1VTqgU_NfzUyY&Mb?GPeV+B9$#rY=|Wl)TvsYIk(ahZl>X*u?xpm6-LIO| z_pznF!bKaxY0dV)z3*0gc+<_z8bZ#JLa$^fD}y)a?0=G$u*YBr@7Ti-XFIufL4z{* znhjO>ryEF+_o%8VWdN*6UF+N&+b%~5am*92jM*TO8=NdM0~3&rylN!P2?I{56a8zI zJLic3k;BB0-ze_EyH4CVK@rbF^=Yh^HG3vc^uw`JbS?*Qz0e`fYJTOJCyJ|>h8)&t zestP6xjx~jwtL(RwPSQLz|i=H_CS4{oxP7$si@`a9B_MeC+p5Vk|mjc0Ywijz`WV~ zVNYC*`kP2xdG`k8Bp(+C9GRANk}>F~<{TPQHT&vM<7^nP-VV#%l%0?}iMp}ExHWsn zi#yMeRwpAt+s=u&Os;o7pj7`@P9zZNs~8tdHC-GJx<_Sy-20K7eHOWRL*Bf-!JEow z<7h*2p-aIND{{wpm$Uj;CLrPCGCC`Emn*t(*I=_E+6^oyX8gPR(!Tk~ z2?M#AU?)Qp-fZH;W;uN>G1$HlDd7^QWC3$NnK-(bM}7a3!YzBN<1JRZcs{+WR}Jt# z>L{&4Z|56(7b;XwxXR0Kn-^C7hrh35<}L$8hFS~_piUawH5w3aO8M(KX|oV^uaohV ze)rGlRP|iwHr*>K3MTVwNG63bm*{sB&XO7wE;TgS$2a^5Pp2KM`fXn%&dc;)&F67e zQ!Cazl+Fj8@0>_`y%_{#9b|L!(dK_5K{JdExyuEmqOZYYRxxz3Gh*HvPm0o251$uz@X>aBc~Ue{4&dgYSwCIziZ_C}zC3o>-H3Ra zwl3ER3X*ImxMn*r5>)^0h&{Ggn^(U9UW$C@htP#UuUG8+4Za%HuNT0G$K$G*)A`fY zdXpCV0jG5+(b4y>ITn2ci1@Nd453<`Y;>y6;g#eF{iO+`LrZiW{in;6P0?!->KujA zYU}qD%*)q2ubcqN|0>|al)T@Mw2c0|OtE++!Nh`S;eu;JTI+AGi`bqyTW6H^lh}ct z(u(uZ9HDW~pS^vmY5~f0T)f^~<2F~k?cm4kRT}{n;9{G+g?*Z;sI)3ZX zw38fHgQ@m4Ics8#>I+^2C&r%_z4nUDDl|1r7u0A zH#%sg_OgQL#a$pMgHrAFZ+07>+^=thee_6=Ru3s zRC#;~V`^dg$D7T3yzKg;Cj?jRUT)4=CCEM{RajypUUB>gB|UmgAXH`914Sz{#U$)e zYG`*SW_3|#3L6flhGuW)aZ{CLi*xg-j~FlCk)x;QBe-dI(dGrvz)=A#0~h(dPORLg zNzQRufPN@VX@1Yf^8~IwH0ej=)7H8hDcaJh!F7M4+?m5)s z4G|n@T||_<;Im7Htw_xNdP%EQBTxgUixhE=wVDSi&;igY6%d@^*+TNw^@${v!RKSK z`k8Wj0fT&Sh(#qeFzo`QLe{v{1qg2LPbhRVpoGsOn7Z9-@*M-RZp|=^C~4k_?LIc~ zrk=z{b?8Vs+J9`%Pyy7Xum)SK+>H|PsS`g>+| z>O2CxzFxSTB(;DF(q~V-J$S#LkCgfDZiuq1huIlA<63s?rKc4awbZp%Vm~~!#MD>6 z5%Ye|wlR-w7w4sN)i73t0GJ)RyVN~!A2RMXg1Ky=kx74$@;U+J6b(gGa4zJ6a@O`) zVoG#$1xZhiG6n~YQ}PVM;#AvKP1|i7@&Lm1qJzI89mjUcvW2%~@+v(DI}a!o&lHZ5 zkQQ_=i16x=93@yNn{%)g+R4hZPE$26BfIuTogTR-!JdH`=Jp~nsf%ZgR9w;yHtf8U z{aNjBGQXQcn`S4J2Rb8_|GbUXWed1XA_JE8@E}a9zPWH@)$?!ehtZFyCFpF^`<*jC z>(Wu=swdRed*NyJk|_dZqj0-?4cGt>cb|WicF3z?U8jaa3tyzSl> z!0aVU7y&%x*Cib1$vJzKna-OM8Ogdgk$1)KXuTS`w)!Fpi82gi?LfsYt??Wrl;0BV zFXgF~?}>jD{dHie2rE}KyGqm3GgX@&#iU-7Zq(zU1E%5~dx@Rw!+#<}xy%Z@=#tx? z0ld|a@-ov0#gfILQ!zho(psa zT1s>~ZqFtVAwS~1bk1*t6WNrI#xofUHN;n{9#zgV3}N$Ops1cfZm*S8%S zTEYw#d&q-ugkfx>(>6I-c+Xfeq+mps{gzIylC_kL2_x3Noupr6NY1OLpHpKHb#LQf z%l67%(#bZ?2iIXlXqIt8E$BRQTQ6Ewp}{^{1REJ%#I)%1u_zy96R$fcXdq_?oYFLd zWdp(F1b5Z0sIdw}al2)`7rq*)$?{*#%aI+dK!3umzf>$GUCN3 zIBqi+rovbWFj;CQfm^_`5V*&l;J7uJGq59z>){47m_MHE><6GV@${xCF!i2d}#E8zDuaT};pIxYHW)Wj}?fa}5e@V%E$lP&0*po=`9=}6al1WC& zfk?C#<(R6r!;-6ku|K(g0yei-SJC4se zY?v<&dGEy>&`w-p=iCtmadvhl{+$n0|4NcFQpm75~=>IRLU?UoB*E+YbU_ca{z+p4UbQ+{O(BS2wrKoK)93rLGk2bw`B&Z?)7 z=e+CNZDY%21|flGw+5*0g`pdizOqVEd8I5K4O|DEFBw9!SqT`^J9qqpRu1*sUU0R| zAt$bcMXjX|&5_WM(Gb!kZrO!XB)TO=C6*=DOL9ZC;yb|4Bn*mgNXXO_WJ}8As1l6u zLE0St_~eEcSkY`b2i#uo=YRZGW%C!Iu4M9?m*SRl!1+yh#&J`v&%6&OLgHTEFPq6> zgS#`t!FGQ-`WyE(^7}7544ei_xEpo~H(u_YkeEP;&LypHH;e`6)<~L5J1KtM!qmze z?UUYq3*cJ&2*D=IZ27nUSzTQ%0ckvcVHv@2`YN z0%LWLX^d*LhM9FT7hs) zM}iU}t)V!(w8ZVpFAA+@Jo_Z$1R$XCMlEt#4~u>K?53rBTG40h!CoR01{p+{&E)7O z0j%O?R5P8Lm6e488wMo*h#Fshemw1WrhhNcaq`bPzdO#rmR1hbQG<@~8(9+hn5$(> z!WB3Lp?Gp2%f*tUr!Rn#MD)%uu9+SN?B5uNN~%8*}tLkOOvjC6I{DU_N-0WZ^uFDhTKikqPBFGp>$0S=&_P+Sr9k$wwCPsFTafnDn zZ(*vl4I=1^t?%BQ_QCZ#X7=q9X2nBmkkaKY2_cC2go?8*Qm~FZl5vj2SC%utC^w)g>Ssk@V8hiTY`C%C=$nJVQkz9 zys4aSZx1AqRbpZ#&PkgHaNR6z-|vhhA}&5e@k5Y z+Y8fLi-&p<6vd%8*ByK+4B?B^TMcL{!yMp%2#f@S-@Rx@pJsr6t+w$$aI2X&+yUnl zVUT2(IQ0wLX55*4TRXq098Q104VL-!pXbuD)xrs)23+93k@ycgeS6xgE&V2{`ak*($={4v%eh@7}#@ z&Fe0Q%G+oLZ`-H)|9X^JGOMw3=U~tO@P;nK-VUU!?P(X`d(TaWuF*-n^X&>WWPsN+ z4o*HzC2vuT+XpKY0|7%Jr6}8Ug=8XZW2hi_z^zmS-UBo?dzczi{yt&G=FZ7FwPx~RTU^&eF|8q^y|3UJn|ARj2e^h$C-HE6`lLB*v z!}jJkg1(`DgoB*mVB&pV$6mpH0H^6u;gx_#(m#!Pp&ZLYKdq9MhdvQWFxd z{kG}-6()5(SEb1^l+8YK1v2gwe*6O8mm-E?Tq5F<%6?HQA5Z=;mILTJe~F%3b>%%D zdVCUb&zkz75@BHrvoH?6IZJ+H&6QgZ5R75Tk2l=^yO7je8-R8e6s2U QUWe*&tz+p&&)xXH06)MPGynhq diff --git a/site/content/en/images/quality_comparison_bbox1.svg b/site/content/en/images/quality_comparison_bbox1.svg new file mode 100644 index 000000000000..2bef3d53e22e --- /dev/null +++ b/site/content/en/images/quality_comparison_bbox1.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + Intersection + Union + + IoU = + + diff --git a/site/content/en/images/quality_comparison_polylines1.png b/site/content/en/images/quality_comparison_polylines1.png new file mode 100644 index 0000000000000000000000000000000000000000..fca37af1283e56a420b480551c1c6f8b558937c8 GIT binary patch literal 9496 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� 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 G4*nl+>|uTY literal 0 HcmV?d00001 diff --git a/site/content/en/images/quality_comparison_skeleton1.svg b/site/content/en/images/quality_comparison_skeleton1.svg new file mode 100644 index 000000000000..ed3fa0cdcf88 --- /dev/null +++ b/site/content/en/images/quality_comparison_skeleton1.svg @@ -0,0 +1,275 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ~5% of the side,used asa point radius + + diff --git a/site/content/en/images/quality_download_report.png b/site/content/en/images/quality_download_report.png new file mode 100644 index 0000000000000000000000000000000000000000..3015d632f5efcfe3bd8a3c3aa073363c93a9b083 GIT binary patch literal 4984 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 RUH*yzbhQjMtJUqJ{|7YpwIu)m literal 0 HcmV?d00001 From d86c816898b2d6250b9d24a1631da70cdc62d633 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 4 Dec 2024 20:30:26 +0300 Subject: [PATCH 106/163] Fix image format in docs (#8775) ### Motivation and context Fixes invalid extension of an image changed in https://github.com/cvat-ai/cvat/pull/8732 ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. --- site/content/en/images/honeypot10.jpg | Bin 0 -> 39031 bytes site/content/en/images/honeypot10.png | Bin 39059 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 site/content/en/images/honeypot10.jpg delete mode 100644 site/content/en/images/honeypot10.png diff --git a/site/content/en/images/honeypot10.jpg b/site/content/en/images/honeypot10.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e4917324a5efba43ba5c4b579ff2a137d097966b GIT binary patch literal 39031 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 NmwA2%EDC*}{a;9_`?3H4 literal 0 HcmV?d00001 diff --git a/site/content/en/images/honeypot10.png b/site/content/en/images/honeypot10.png deleted file mode 100644 index 8d77b290a6b95c4616d67de5fcaba4be2182cf80..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39059 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 Date: Thu, 5 Dec 2024 11:48:35 +0300 Subject: [PATCH 107/163] Keep preview in cache during task creation (#8766) ### Motivation and context ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [x] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced a preview image generation feature that activates after task creation, enhancing task management capabilities. --- cvat/apps/engine/task.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 542f1521b053..c1a27c38f392 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -30,6 +30,7 @@ from cvat.apps.engine import models from cvat.apps.engine.log import ServerLogManager +from cvat.apps.engine.frame_provider import TaskFrameProvider from cvat.apps.engine.media_extractors import ( MEDIA_TYPES, CachingMediaIterator, IMediaReader, ImageListReader, Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter, RandomAccessIterator, @@ -1499,6 +1500,9 @@ def _to_abs_frame(rel_frame: int) -> int: ): _create_static_chunks(db_task, media_extractor=extractor, upload_dir=upload_dir) + # Prepare the preview image and save it in the cache + TaskFrameProvider(db_task=db_task).get_preview() + def _create_static_chunks(db_task: models.Task, *, media_extractor: IMediaReader, upload_dir: str): @attrs.define class _ChunkProgressUpdater: From 683804e4393dda31ae5f2dbeb34fb780a3dc165c Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Thu, 5 Dec 2024 11:38:39 +0200 Subject: [PATCH 108/163] Bump DRF to 3.15.2 (#8773) I'm looking to add a `Content-Security-Policy` to API responses, and there were some important improvements for that in 3.15.0 (e.g. encode/django-rest-framework#8784). --- cvat/requirements/base.in | 2 +- cvat/requirements/base.txt | 5 ++-- cvat/schema.yml | 51 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/cvat/requirements/base.in b/cvat/requirements/base.in index edd8c065dbc0..6aafd3e658aa 100644 --- a/cvat/requirements/base.in +++ b/cvat/requirements/base.in @@ -29,7 +29,7 @@ django-health-check>=3.18.1,<4 django-rq==2.8.1 django-sendfile2==0.7.0 Django~=4.2.7 -djangorestframework~=3.14.0 +djangorestframework>=3.15.2,<4 drf-spectacular==0.26.2 furl==2.1.0 google-cloud-storage==1.42.0 diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index ffaf10bd0e71..bee9701a94a7 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -1,4 +1,4 @@ -# SHA1:9ff984f33ae139c68d90acc3e338c6cef7ecf6e9 +# SHA1:275a663d91764ea3126531729179a07e95b657e8 # # This file is autogenerated by pip-compile-multi # To update, run: @@ -105,7 +105,7 @@ django-rq==2.8.1 # via -r cvat/requirements/base.in django-sendfile2==0.7.0 # via -r cvat/requirements/base.in -djangorestframework==3.14.0 +djangorestframework==3.15.2 # via # -r cvat/requirements/base.in # dj-rest-auth @@ -264,7 +264,6 @@ python3-saml==1.16.0 pytz==2024.2 # via # clickhouse-connect - # djangorestframework # pandas pyunpack==0.2.1 # via -r cvat/requirements/base.in diff --git a/cvat/schema.yml b/cvat/schema.yml index 44e5f19ca2dc..4b261f66c905 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -8990,6 +8990,9 @@ components: - slug PaginatedAnnotationConflictList: type: object + required: + - count + - results properties: count: type: integer @@ -9010,6 +9013,9 @@ components: $ref: '#/components/schemas/AnnotationConflict' PaginatedCloudStorageReadList: type: object + required: + - count + - results properties: count: type: integer @@ -9030,6 +9036,9 @@ components: $ref: '#/components/schemas/CloudStorageRead' PaginatedCommentReadList: type: object + required: + - count + - results properties: count: type: integer @@ -9050,6 +9059,9 @@ components: $ref: '#/components/schemas/CommentRead' PaginatedInvitationReadList: type: object + required: + - count + - results properties: count: type: integer @@ -9070,6 +9082,9 @@ components: $ref: '#/components/schemas/InvitationRead' PaginatedIssueReadList: type: object + required: + - count + - results properties: count: type: integer @@ -9090,6 +9105,9 @@ components: $ref: '#/components/schemas/IssueRead' PaginatedJobReadList: type: object + required: + - count + - results properties: count: type: integer @@ -9110,6 +9128,9 @@ components: $ref: '#/components/schemas/JobRead' PaginatedLabelList: type: object + required: + - count + - results properties: count: type: integer @@ -9130,6 +9151,9 @@ components: $ref: '#/components/schemas/Label' PaginatedMembershipReadList: type: object + required: + - count + - results properties: count: type: integer @@ -9150,6 +9174,9 @@ components: $ref: '#/components/schemas/MembershipRead' PaginatedMetaUserList: type: object + required: + - count + - results properties: count: type: integer @@ -9170,6 +9197,9 @@ components: $ref: '#/components/schemas/MetaUser' PaginatedOrganizationReadList: type: object + required: + - count + - results properties: count: type: integer @@ -9190,6 +9220,9 @@ components: $ref: '#/components/schemas/OrganizationRead' PaginatedProjectReadList: type: object + required: + - count + - results properties: count: type: integer @@ -9210,6 +9243,9 @@ components: $ref: '#/components/schemas/ProjectRead' PaginatedQualityReportList: type: object + required: + - count + - results properties: count: type: integer @@ -9230,6 +9266,9 @@ components: $ref: '#/components/schemas/QualityReport' PaginatedQualitySettingsList: type: object + required: + - count + - results properties: count: type: integer @@ -9250,6 +9289,9 @@ components: $ref: '#/components/schemas/QualitySettings' PaginatedRequestList: type: object + required: + - count + - results properties: count: type: integer @@ -9270,6 +9312,9 @@ components: $ref: '#/components/schemas/Request' PaginatedTaskReadList: type: object + required: + - count + - results properties: count: type: integer @@ -9290,6 +9335,9 @@ components: $ref: '#/components/schemas/TaskRead' PaginatedWebhookDeliveryReadList: type: object + required: + - count + - results properties: count: type: integer @@ -9310,6 +9358,9 @@ components: $ref: '#/components/schemas/WebhookDeliveryRead' PaginatedWebhookReadList: type: object + required: + - count + - results properties: count: type: integer From ebe3dd6f996c624b18fc63a4f613d3091b35e61c Mon Sep 17 00:00:00 2001 From: Kirill Lakhov Date: Thu, 5 Dec 2024 19:24:01 +0300 Subject: [PATCH 109/163] Fixed sonar cloud issues (#8779) --- tests/python/cli/test_cli.py | 2 +- tests/python/sdk/test_auto_annotation.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/python/cli/test_cli.py b/tests/python/cli/test_cli.py index 8008f44270ab..5a2fb6b0506d 100644 --- a/tests/python/cli/test_cli.py +++ b/tests/python/cli/test_cli.py @@ -362,7 +362,7 @@ def test_auto_annotate_with_threshold(self, fxt_new_task: Task): ) annotations = fxt_new_task.get_annotations() - assert annotations.shapes[0].points[0] == 0.75 + assert annotations.shapes[0].points[0] == 0.75 # python:S1244 NOSONAR def test_auto_annotate_with_cmtp(self, fxt_new_task: Task): self.run_cli( diff --git a/tests/python/sdk/test_auto_annotation.py b/tests/python/sdk/test_auto_annotation.py index 0d22100cfb15..9c41112edb0a 100644 --- a/tests/python/sdk/test_auto_annotation.py +++ b/tests/python/sdk/test_auto_annotation.py @@ -290,7 +290,7 @@ def detect( conf_threshold=0.75, ) - assert received_threshold == 0.75 + assert received_threshold == 0.75 # python:S1244 NOSONAR cvataa.annotate_task( self.client, From 682d7dd753e84b010f22c295d8427f51ab7bafbd Mon Sep 17 00:00:00 2001 From: Kirill Lakhov Date: Fri, 6 Dec 2024 10:28:45 +0300 Subject: [PATCH 110/163] Enqueue job to prepare chunk in case of honey pot job change (#8772) ### Motivation and context Fixed a problem that occured in case if honeypots were changed in job and a chunk inside of job was requested immideately after that. (In such a case rq_job still exists but in "finished" status) ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [x] I have created a changelog fragment - ~~[ ] I have updated the documentation accordingly~~ - [x] I have added tests to cover my changes - ~~[ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword))~~ - ~~[ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning))~~ ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **Bug Fixes** - Improved job enqueueing logic to prevent issues with deleted jobs. - Enhanced error handling for cache item deletion, providing clearer logging for failures. --------- Co-authored-by: Andrey Zhavoronkov Co-authored-by: Maxim Zhiltsov --- .../20241204_165337_klakhov_fix_chunk_job_enqueue.md | 4 ++++ cvat/apps/engine/cache.py | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20241204_165337_klakhov_fix_chunk_job_enqueue.md diff --git a/changelog.d/20241204_165337_klakhov_fix_chunk_job_enqueue.md b/changelog.d/20241204_165337_klakhov_fix_chunk_job_enqueue.md new file mode 100644 index 000000000000..9b866382c6f9 --- /dev/null +++ b/changelog.d/20241204_165337_klakhov_fix_chunk_job_enqueue.md @@ -0,0 +1,4 @@ +### Fixed + +- Failed request for a chunk inside a job after it was recently modified by updating `validation_layout` + () diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 197c10f14d71..30122a1826f0 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -37,6 +37,7 @@ import PIL.Image import PIL.ImageOps import rq +from rq.job import JobStatus as RQJobStatus from django.conf import settings from django.core.cache import caches from django.db import models as django_models @@ -91,7 +92,12 @@ def enqueue_create_chunk_job( with get_rq_lock_for_job(queue, rq_job_id, blocking_timeout=blocking_timeout): rq_job = queue.fetch_job(rq_job_id) - if not rq_job: + if not rq_job or ( + # Enqueue the job if the chunk was deleted but the RQ job still exists. + # This can happen in cases involving jobs with honeypots and + # if the job wasn't collected by the requesting process for any reason. + rq_job.get_status(refresh=False) in {RQJobStatus.FINISHED, RQJobStatus.FAILED, RQJobStatus.CANCELED} + ): rq_job = queue.enqueue( create_callback, job_id=rq_job_id, From b5c09710e9a7601056e9054f2ff8fa84ac4b0252 Mon Sep 17 00:00:00 2001 From: Kirill Lakhov Date: Fri, 6 Dec 2024 12:23:38 +0300 Subject: [PATCH 111/163] Update test assets for quality tests (#8763) ### Motivation and context ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - ~~[ ] I have created a changelog fragment ~~ - ~~[ ] I have updated the documentation accordingly~~ - ~~[ ] I have added tests to cover my changes~~ - ~~[ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword))~~ - ~~[ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning))~~ ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Enhanced task configuration options with additional parameters for more specific task handling. - **Bug Fixes** - Improved functionality of the task specification to conditionally include relevant properties based on new parameters. --------- Co-authored-by: Boris Sekachev --- tests/cypress/support/default-specs.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/cypress/support/default-specs.js b/tests/cypress/support/default-specs.js index ea07bab747b2..5e59afff47d1 100644 --- a/tests/cypress/support/default-specs.js +++ b/tests/cypress/support/default-specs.js @@ -4,13 +4,17 @@ function defaultTaskSpec({ labelName, + labelType, taskName, serverFiles, + startFrame, + frameFilter, + segmentSize, validationParams, }) { const taskSpec = { labels: [ - { name: labelName, attributes: [], type: 'any' }, + { name: labelName, attributes: [], type: labelType || 'any' }, ], name: taskName, project_id: null, @@ -18,6 +22,10 @@ function defaultTaskSpec({ target_storage: { location: 'local' }, }; + if (segmentSize) { + taskSpec.segment_size = segmentSize; + } + const dataSpec = { server_files: serverFiles, image_quality: 70, @@ -25,6 +33,12 @@ function defaultTaskSpec({ use_cache: true, sorting_method: (validationParams && validationParams.mode === 'gt_pool') ? 'random' : 'lexicographical', }; + if (startFrame) { + dataSpec.start_frame = startFrame; + } + if (frameFilter) { + dataSpec.frame_filter = frameFilter; + } const extras = {}; if (validationParams) { From a091d15bcbeb5b2b47359bd12a44fcc5492a02e3 Mon Sep 17 00:00:00 2001 From: Andrey Zhavoronkov Date: Fri, 6 Dec 2024 15:52:38 +0300 Subject: [PATCH 112/163] Fix infinite lock for chunk preparing (#8769) ### Motivation and context ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [x] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced a new constant `CVAT_CHUNK_LOCK_TIMEOUT` to manage lock acquisition duration during chunk cache operations. - **Improvements** - Enhanced concurrency handling in cache operations with a new locking mechanism. - Streamlined cache item creation by simplifying method logic and reducing redundancy. - **Bug Fixes** - Updated method signatures to improve functionality and maintainability of the caching mechanism. --- ...303_andrey_fix_infinite_lock_for_chunks.md | 4 ++ cvat/apps/engine/cache.py | 42 ++++++++++--------- cvat/apps/engine/utils.py | 5 ++- 3 files changed, 31 insertions(+), 20 deletions(-) create mode 100644 changelog.d/20241206_104303_andrey_fix_infinite_lock_for_chunks.md diff --git a/changelog.d/20241206_104303_andrey_fix_infinite_lock_for_chunks.md b/changelog.d/20241206_104303_andrey_fix_infinite_lock_for_chunks.md new file mode 100644 index 000000000000..7f11306a7cef --- /dev/null +++ b/changelog.d/20241206_104303_andrey_fix_infinite_lock_for_chunks.md @@ -0,0 +1,4 @@ +### Fixed + +- Possible endless lock acquisition for chunk preparation job + () diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 30122a1826f0..0ecd7fcc010c 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -84,12 +84,11 @@ def enqueue_create_chunk_job( rq_job_id: str, create_callback: Callback, *, - blocking_timeout: int = 50, rq_job_result_ttl: int = 60, rq_job_failure_ttl: int = 3600 * 24 * 14, # 2 weeks ) -> rq.job.Job: try: - with get_rq_lock_for_job(queue, rq_job_id, blocking_timeout=blocking_timeout): + with get_rq_lock_for_job(queue, rq_job_id): rq_job = queue.fetch_job(rq_job_id) if not rq_job or ( @@ -205,11 +204,13 @@ def _get_or_set_cache_item( cache_item_ttl=cache_item_ttl, ) - def _get_queue(self) -> rq.Queue: - return django_rq.get_queue(self._QUEUE_NAME) + @classmethod + def _get_queue(cls) -> rq.Queue: + return django_rq.get_queue(cls._QUEUE_NAME) - def _make_queue_job_id(self, key: str) -> str: - return f"{self._QUEUE_JOB_PREFIX_TASK}{key}" + @classmethod + def _make_queue_job_id(cls, key: str) -> str: + return f"{cls._QUEUE_JOB_PREFIX_TASK}{key}" @staticmethod def _drop_return_value(func: Callable[..., DataWithMime], *args: Any, **kwargs: Any): @@ -228,7 +229,15 @@ def _create_and_set_cache_item( item = (item_data[0], item_data[1], cls._get_checksum(item_data_bytes), timestamp) if item_data_bytes: cache = cls._cache() - cache.set(key, item, timeout=cache_item_ttl or cache.default_timeout) + with get_rq_lock_for_job( + cls._get_queue(), + key, + ): + cached_item = cache.get(key) + if cached_item is not None and timestamp <= cached_item[3]: + item = cached_item + else: + cache.set(key, item, timeout=cache_item_ttl or cache.default_timeout) return item @@ -239,22 +248,17 @@ def _create_cache_item( *, cache_item_ttl: Optional[int] = None, ) -> _CacheItem: - - queue = self._get_queue() - rq_id = self._make_queue_job_id(key) - slogger.glob.info(f"Starting to prepare chunk: key {key}") if _is_run_inside_rq(): - with get_rq_lock_for_job(queue, rq_id, timeout=None, blocking_timeout=None): - item = self._create_and_set_cache_item( - key, - create_callback, - cache_item_ttl=cache_item_ttl, - ) + item = self._create_and_set_cache_item( + key, + create_callback, + cache_item_ttl=cache_item_ttl, + ) else: rq_job = enqueue_create_chunk_job( - queue=queue, - rq_job_id=rq_id, + queue=self._get_queue(), + rq_job_id=self._make_queue_job_id(key), create_callback=Callback( callable=self._drop_return_value, args=[ diff --git a/cvat/apps/engine/utils.py b/cvat/apps/engine/utils.py index 72cb52eb5168..59409ceb69dd 100644 --- a/cvat/apps/engine/utils.py +++ b/cvat/apps/engine/utils.py @@ -210,8 +210,11 @@ def get_rq_lock_by_user(queue: DjangoRQ, user_id: int, *, timeout: Optional[int] ) return nullcontext() -def get_rq_lock_for_job(queue: DjangoRQ, rq_id: str, *, timeout: Optional[int] = 60, blocking_timeout: Optional[int] = None) -> Lock: +def get_rq_lock_for_job(queue: DjangoRQ, rq_id: str, *, timeout: int = 60, blocking_timeout: int = 50) -> Lock: # lock timeout corresponds to the nginx request timeout (proxy_read_timeout) + + assert timeout is not None + assert blocking_timeout is not None return queue.connection.lock( name=f'lock-for-job-{rq_id}'.lower(), timeout=timeout, From ca3d70078b9e729d12e3e5969fd46d6b8a55fb98 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Fri, 6 Dec 2024 14:58:08 +0200 Subject: [PATCH 113/163] CLI: log to stderr instead of stdout (#8784) Since the `ls` command produces machine-readable output, we need to ensure that any log messages produced do not corrupt that output. Logging to stderr is also conventional; Python's `StreamHandler` even defaults to it. This breaks the `import` command tests, because previously they were looking at the last log message. I don't think we should be testing log messages, so add a proper `print` to this command. This also makes it consistent with the `create` command. --- changelog.d/20241206_135902_roman_cli_logging.md | 4 ++++ cvat-cli/src/cvat_cli/_internal/commands.py | 3 ++- cvat-cli/src/cvat_cli/_internal/common.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 changelog.d/20241206_135902_roman_cli_logging.md diff --git a/changelog.d/20241206_135902_roman_cli_logging.md b/changelog.d/20241206_135902_roman_cli_logging.md new file mode 100644 index 000000000000..30ecb58a8921 --- /dev/null +++ b/changelog.d/20241206_135902_roman_cli_logging.md @@ -0,0 +1,4 @@ +### Changed + +- \[CLI\] Log messages are now printed on stderr rather than stdout + () diff --git a/cvat-cli/src/cvat_cli/_internal/commands.py b/cvat-cli/src/cvat_cli/_internal/commands.py index 324d427a64b8..efda05c58454 100644 --- a/cvat-cli/src/cvat_cli/_internal/commands.py +++ b/cvat-cli/src/cvat_cli/_internal/commands.py @@ -418,11 +418,12 @@ def configure_parser(self, parser: argparse.ArgumentParser) -> None: ) def execute(self, client: Client, *, filename: str, status_check_period: int) -> None: - client.tasks.create_from_backup( + task = client.tasks.create_from_backup( filename=filename, status_check_period=status_check_period, pbar=DeferredTqdmProgressReporter(), ) + print(f"Created task ID", task.id) @COMMANDS.command_class("auto-annotate") diff --git a/cvat-cli/src/cvat_cli/_internal/common.py b/cvat-cli/src/cvat_cli/_internal/common.py index 415a1340958e..6f37e3d74eaa 100644 --- a/cvat-cli/src/cvat_cli/_internal/common.py +++ b/cvat-cli/src/cvat_cli/_internal/common.py @@ -74,7 +74,7 @@ def configure_logger(logger: logging.Logger, parsed_args: argparse.Namespace) -> formatter = logging.Formatter( "[%(asctime)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", style="%" ) - handler = logging.StreamHandler(sys.stdout) + handler = logging.StreamHandler(sys.stderr) handler.setFormatter(formatter) logger.addHandler(handler) logger.setLevel(level) From caff6eebc9ab65fa07e6ec00b70f77839280d40a Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Fri, 6 Dec 2024 16:43:15 +0200 Subject: [PATCH 114/163] Remove ModelDeleteMixin from TasksRepo base classes (#8786) This mixin is for entities, not repositories. Presumably it was added by mistake. The `remove` method it adds doesn't work on a repository, so removing it doesn't break anything. --- cvat-sdk/cvat_sdk/core/proxies/tasks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cvat-sdk/cvat_sdk/core/proxies/tasks.py b/cvat-sdk/cvat_sdk/core/proxies/tasks.py index e0db111f8511..9654399bc8ce 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/tasks.py +++ b/cvat-sdk/cvat_sdk/core/proxies/tasks.py @@ -286,7 +286,6 @@ class TasksRepo( ModelCreateMixin[Task, models.ITaskWriteRequest], ModelRetrieveMixin[Task], ModelListMixin[Task], - ModelDeleteMixin, ): _entity_type = Task From 12eec5481bb3d4e6219bb8214df524f27b3037e3 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Fri, 6 Dec 2024 20:41:33 +0200 Subject: [PATCH 115/163] Fixed issue: Cannot read properties of undefined (reading 'getUpdated') (#8785) --- ...20241206_153723_sekachev.bs_fixed_issue.md | 4 ++ cvat-core/package.json | 2 +- cvat-core/src/frames.ts | 64 +++++++++++-------- 3 files changed, 44 insertions(+), 26 deletions(-) create mode 100644 changelog.d/20241206_153723_sekachev.bs_fixed_issue.md diff --git a/changelog.d/20241206_153723_sekachev.bs_fixed_issue.md b/changelog.d/20241206_153723_sekachev.bs_fixed_issue.md new file mode 100644 index 000000000000..badf4b20aac4 --- /dev/null +++ b/changelog.d/20241206_153723_sekachev.bs_fixed_issue.md @@ -0,0 +1,4 @@ +### Fixed + +- Fixed issue: Cannot read properties of undefined (reading 'getUpdated') + () diff --git a/cvat-core/package.json b/cvat-core/package.json index 6b9039673812..8e27f80f0b98 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "15.3.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/frames.ts b/cvat-core/src/frames.ts index dfdd35684178..3305edfc5aab 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -536,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]; } @@ -834,7 +848,7 @@ export async function patchMeta(jobID: number): Promise { const updatedFields = meta.getUpdated(); if (Object.keys(updatedFields).length) { - frameMetaCache[jobID] = saveJobMeta(meta, jobID); + await saveJobMeta(meta, jobID); } const newMeta = await frameMetaCache[jobID]; return newMeta; From 61c6a016b91208cc2d8c3ee80fcebab8f017bf66 Mon Sep 17 00:00:00 2001 From: Andrey Zhavoronkov Date: Mon, 9 Dec 2024 09:33:41 +0300 Subject: [PATCH 116/163] Memory optimization for image chunk preparation (#8778) ### Motivation and context ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [x] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit ## Release Notes - **New Features** - Enhanced image loading functionality with a new `load_image` feature, improving handling of various image formats and EXIF rotation. - Improved segment preview generation, specifically for 3D segments. - **Bug Fixes** - Resolved issues with TIFF image handling, ensuring correct image rotation and processing. - **Improvements** - Refined task creation process with better validation checks and progress tracking for long-running tasks. - Updated utility functions for better type safety and error handling. --- changelog.d/20241205_165408_andrey_memory.md | 4 +++ cvat/apps/engine/cache.py | 8 ++---- cvat/apps/engine/media_extractors.py | 30 ++++++++++++-------- cvat/apps/engine/task.py | 5 ++-- cvat/apps/engine/utils.py | 4 --- 5 files changed, 27 insertions(+), 24 deletions(-) create mode 100644 changelog.d/20241205_165408_andrey_memory.md diff --git a/changelog.d/20241205_165408_andrey_memory.md b/changelog.d/20241205_165408_andrey_memory.md new file mode 100644 index 000000000000..fa7d959977f8 --- /dev/null +++ b/changelog.d/20241205_165408_andrey_memory.md @@ -0,0 +1,4 @@ +### Fixed + +- Memory consumption during preparation of image chunks + () diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 0ecd7fcc010c..e27b697e41c4 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -62,14 +62,10 @@ VideoReaderWithManifest, ZipChunkWriter, ZipCompressedChunkWriter, -) -from cvat.apps.engine.rq_job_handler import RQJobMetaField -from cvat.apps.engine.utils import ( - CvatChunkTimestampMismatchError, - get_rq_lock_for_job, load_image, - md5_hash, ) +from cvat.apps.engine.rq_job_handler import RQJobMetaField +from cvat.apps.engine.utils import CvatChunkTimestampMismatchError, get_rq_lock_for_job, md5_hash from utils.dataset_manifest import ImageManifestManager slogger = ServerLogManager(__name__) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index c923083b18b3..3e7b8e17a31b 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -101,6 +101,12 @@ def image_size_within_orientation(img: Image.Image): def has_exif_rotation(img: Image.Image): return img.getexif().get(ORIENTATION_EXIF_TAG, ORIENTATION.NORMAL_HORIZONTAL) != ORIENTATION.NORMAL_HORIZONTAL + +def load_image(image: tuple[str, str, str])-> tuple[Image.Image, str, str]: + with Image.open(image[0]) as pil_img: + pil_img.load() + return pil_img, image[1], image[2] + _T = TypeVar("_T") @@ -837,13 +843,15 @@ def _compress_image(source_image: av.VideoFrame | io.IOBase | Image.Image, quali if isinstance(source_image, av.VideoFrame): image = source_image.to_image() elif isinstance(source_image, io.IOBase): - with Image.open(source_image) as _img: - image = ImageOps.exif_transpose(_img) + image, _, _ = load_image((source_image, None, None)) elif isinstance(source_image, Image.Image): - image = ImageOps.exif_transpose(source_image) + image = source_image assert image is not None + if has_exif_rotation(image): + image = ImageOps.exif_transpose(image) + # Ensure image data fits into 8bit per pixel before RGB conversion as PIL clips values on conversion if image.mode == "I": # Image mode is 32bit integer pixels. @@ -868,16 +876,14 @@ def _compress_image(source_image: av.VideoFrame | io.IOBase | Image.Image, quali image = Image.fromarray(image, mode="L") # 'L' := Unsigned Integer 8, Grayscale image = ImageOps.equalize(image) # The Images need equalization. High resolution with 16-bit but only small range that actually contains information - converted_image = image.convert('RGB') + if image.mode != 'RGB' and image.mode != 'L': + image = image.convert('RGB') - try: - buf = io.BytesIO() - converted_image.save(buf, format='JPEG', quality=quality, optimize=True) - buf.seek(0) - width, height = converted_image.size - return width, height, buf - finally: - converted_image.close() + buf = io.BytesIO() + image.save(buf, format='JPEG', quality=quality, optimize=True) + buf.seek(0) + + return image.width, image.height, buf @abstractmethod def save_as_chunk(self, images, chunk_path): diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index c1a27c38f392..4e10ea9e5dab 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -34,12 +34,13 @@ from cvat.apps.engine.media_extractors import ( MEDIA_TYPES, CachingMediaIterator, IMediaReader, ImageListReader, Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter, RandomAccessIterator, - ValidateDimension, ZipChunkWriter, ZipCompressedChunkWriter, get_mime, sort + ValidateDimension, ZipChunkWriter, ZipCompressedChunkWriter, get_mime, sort, + load_image, ) from cvat.apps.engine.models import RequestAction, RequestTarget from cvat.apps.engine.utils import ( av_scan_paths, format_list,get_rq_job_meta, - define_dependent_job, get_rq_lock_by_user, load_image + define_dependent_job, get_rq_lock_by_user ) from cvat.apps.engine.rq_job_handler import RQId from cvat.utils.http import make_requests_session, PROXIES_FOR_UNTRUSTED_URLS diff --git a/cvat/apps/engine/utils.py b/cvat/apps/engine/utils.py index 59409ceb69dd..5d383df3465e 100644 --- a/cvat/apps/engine/utils.py +++ b/cvat/apps/engine/utils.py @@ -377,10 +377,6 @@ def sendfile( return _sendfile(request, filename, attachment, attachment_filename, mimetype, encoding) -def load_image(image: tuple[str, str, str])-> tuple[Image.Image, str, str]: - pil_img = Image.open(image[0]) - pil_img.load() - return pil_img, image[1], image[2] def build_backup_file_name( *, From 195f4272c07e55ef49c10e06028eff1f809554cf Mon Sep 17 00:00:00 2001 From: "cvat-bot[bot]" <147643061+cvat-bot[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 06:35:55 +0000 Subject: [PATCH 117/163] Prepare release v2.23.1 --- CHANGELOG.md | 41 +++++++++++++++++++ ...658_dmitrii.lavrukhin_no_queryset_cache.md | 5 --- ...21_181517_mzhiltso_fix_allocation_table.md | 4 -- ...45739_sekachev.bs_simplified_navigation.md | 4 -- ..._may_navigate_forward_when_model_opened.md | 4 -- .../20241128_112750_sekachev.bs_fixed_fit.md | 4 -- ..._sekachev.bs_fixed_create_obj_url_color.md | 4 -- ...04_165337_klakhov_fix_chunk_job_enqueue.md | 4 -- changelog.d/20241205_165408_andrey_memory.md | 4 -- ...303_andrey_fix_infinite_lock_for_chunks.md | 4 -- .../20241206_135902_roman_cli_logging.md | 4 -- ...20241206_153723_sekachev.bs_fixed_issue.md | 4 -- cvat/__init__.py | 2 +- docker-compose.yml | 20 ++++----- helm-chart/values.yaml | 4 +- 15 files changed, 54 insertions(+), 58 deletions(-) delete mode 100644 changelog.d/20241113_130658_dmitrii.lavrukhin_no_queryset_cache.md delete mode 100644 changelog.d/20241121_181517_mzhiltso_fix_allocation_table.md delete mode 100644 changelog.d/20241127_145739_sekachev.bs_simplified_navigation.md delete mode 100644 changelog.d/20241127_170908_sekachev.bs_fixed_user_may_navigate_forward_when_model_opened.md delete mode 100644 changelog.d/20241128_112750_sekachev.bs_fixed_fit.md delete mode 100644 changelog.d/20241128_131448_sekachev.bs_fixed_create_obj_url_color.md delete mode 100644 changelog.d/20241204_165337_klakhov_fix_chunk_job_enqueue.md delete mode 100644 changelog.d/20241205_165408_andrey_memory.md delete mode 100644 changelog.d/20241206_104303_andrey_fix_infinite_lock_for_chunks.md delete mode 100644 changelog.d/20241206_135902_roman_cli_logging.md delete mode 100644 changelog.d/20241206_153723_sekachev.bs_fixed_issue.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a9143f436f05..f798c1fa2766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + +## \[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 diff --git a/changelog.d/20241113_130658_dmitrii.lavrukhin_no_queryset_cache.md b/changelog.d/20241113_130658_dmitrii.lavrukhin_no_queryset_cache.md deleted file mode 100644 index 8efcd99d7bf8..000000000000 --- a/changelog.d/20241113_130658_dmitrii.lavrukhin_no_queryset_cache.md +++ /dev/null @@ -1,5 +0,0 @@ -### Fixed - -- Optimized memory consumption and reduced the number of database queries - when importing annotations to a task with a lot of jobs and images - () diff --git a/changelog.d/20241121_181517_mzhiltso_fix_allocation_table.md b/changelog.d/20241121_181517_mzhiltso_fix_allocation_table.md deleted file mode 100644 index a1af44276acc..000000000000 --- a/changelog.d/20241121_181517_mzhiltso_fix_allocation_table.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Incorrect display of validation frames on the task quality management page - () diff --git a/changelog.d/20241127_145739_sekachev.bs_simplified_navigation.md b/changelog.d/20241127_145739_sekachev.bs_simplified_navigation.md deleted file mode 100644 index 4fb6385e1d21..000000000000 --- a/changelog.d/20241127_145739_sekachev.bs_simplified_navigation.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Player may navigate to removed frames when playing - () diff --git a/changelog.d/20241127_170908_sekachev.bs_fixed_user_may_navigate_forward_when_model_opened.md b/changelog.d/20241127_170908_sekachev.bs_fixed_user_may_navigate_forward_when_model_opened.md deleted file mode 100644 index edcc311a5ce2..000000000000 --- a/changelog.d/20241127_170908_sekachev.bs_fixed_user_may_navigate_forward_when_model_opened.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- User may navigate forward with a keyboard when a modal opened - () diff --git a/changelog.d/20241128_112750_sekachev.bs_fixed_fit.md b/changelog.d/20241128_112750_sekachev.bs_fixed_fit.md deleted file mode 100644 index cb5845dee0f7..000000000000 --- a/changelog.d/20241128_112750_sekachev.bs_fixed_fit.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- fit:canvas event is not generated if to fit it from the controls sidebar - () diff --git a/changelog.d/20241128_131448_sekachev.bs_fixed_create_obj_url_color.md b/changelog.d/20241128_131448_sekachev.bs_fixed_create_obj_url_color.md deleted file mode 100644 index 2e80a5343c99..000000000000 --- a/changelog.d/20241128_131448_sekachev.bs_fixed_create_obj_url_color.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Color of 'Create object URL' button for a not saved on the server object - () diff --git a/changelog.d/20241204_165337_klakhov_fix_chunk_job_enqueue.md b/changelog.d/20241204_165337_klakhov_fix_chunk_job_enqueue.md deleted file mode 100644 index 9b866382c6f9..000000000000 --- a/changelog.d/20241204_165337_klakhov_fix_chunk_job_enqueue.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Failed request for a chunk inside a job after it was recently modified by updating `validation_layout` - () diff --git a/changelog.d/20241205_165408_andrey_memory.md b/changelog.d/20241205_165408_andrey_memory.md deleted file mode 100644 index fa7d959977f8..000000000000 --- a/changelog.d/20241205_165408_andrey_memory.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Memory consumption during preparation of image chunks - () diff --git a/changelog.d/20241206_104303_andrey_fix_infinite_lock_for_chunks.md b/changelog.d/20241206_104303_andrey_fix_infinite_lock_for_chunks.md deleted file mode 100644 index 7f11306a7cef..000000000000 --- a/changelog.d/20241206_104303_andrey_fix_infinite_lock_for_chunks.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Possible endless lock acquisition for chunk preparation job - () diff --git a/changelog.d/20241206_135902_roman_cli_logging.md b/changelog.d/20241206_135902_roman_cli_logging.md deleted file mode 100644 index 30ecb58a8921..000000000000 --- a/changelog.d/20241206_135902_roman_cli_logging.md +++ /dev/null @@ -1,4 +0,0 @@ -### Changed - -- \[CLI\] Log messages are now printed on stderr rather than stdout - () diff --git a/changelog.d/20241206_153723_sekachev.bs_fixed_issue.md b/changelog.d/20241206_153723_sekachev.bs_fixed_issue.md deleted file mode 100644 index badf4b20aac4..000000000000 --- a/changelog.d/20241206_153723_sekachev.bs_fixed_issue.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Fixed issue: Cannot read properties of undefined (reading 'getUpdated') - () diff --git a/cvat/__init__.py b/cvat/__init__.py index cb5542641a13..48299beff13a 100644 --- a/cvat/__init__.py +++ b/cvat/__init__.py @@ -4,6 +4,6 @@ from cvat.utils.version import get_version -VERSION = (2, 23, 1, "alpha", 0) +VERSION = (2, 23, 1, "final", 0) __version__ = get_version(VERSION) diff --git a/docker-compose.yml b/docker-compose.yml index a921b70cbf9f..b00ff6ba3ac6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,7 @@ services: cvat_server: container_name: cvat_server - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.1} restart: always depends_on: <<: *backend-deps @@ -113,7 +113,7 @@ services: cvat_utils: container_name: cvat_utils - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.1} restart: always depends_on: *backend-deps environment: @@ -130,7 +130,7 @@ services: cvat_worker_import: container_name: cvat_worker_import - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.1} restart: always depends_on: *backend-deps environment: @@ -146,7 +146,7 @@ services: cvat_worker_export: container_name: cvat_worker_export - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.1} restart: always depends_on: *backend-deps environment: @@ -162,7 +162,7 @@ services: cvat_worker_annotation: container_name: cvat_worker_annotation - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.1} restart: always depends_on: *backend-deps environment: @@ -178,7 +178,7 @@ services: cvat_worker_webhooks: container_name: cvat_worker_webhooks - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.1} restart: always depends_on: *backend-deps environment: @@ -194,7 +194,7 @@ services: cvat_worker_quality_reports: container_name: cvat_worker_quality_reports - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.1} restart: always depends_on: *backend-deps environment: @@ -210,7 +210,7 @@ services: cvat_worker_analytics_reports: container_name: cvat_worker_analytics_reports - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.1} restart: always depends_on: *backend-deps environment: @@ -226,7 +226,7 @@ services: cvat_worker_chunks: container_name: cvat_worker_chunks - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.23.1} restart: always depends_on: *backend-deps environment: @@ -242,7 +242,7 @@ services: cvat_ui: container_name: cvat_ui - image: cvat/ui:${CVAT_VERSION:-dev} + image: cvat/ui:${CVAT_VERSION:-v2.23.1} restart: always depends_on: - cvat_server diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index ae0180efd972..be05c90f1960 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -139,7 +139,7 @@ cvat: additionalVolumeMounts: [] replicas: 1 image: cvat/server - tag: dev + tag: v2.23.1 imagePullPolicy: Always permissionFix: enabled: true @@ -162,7 +162,7 @@ cvat: frontend: replicas: 1 image: cvat/ui - tag: dev + tag: v2.23.1 imagePullPolicy: Always labels: {} # test: test From e59cd7414a8070fb34bb43088cf82afe09eac74f Mon Sep 17 00:00:00 2001 From: "cvat-bot[bot]" <147643061+cvat-bot[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 08:17:04 +0000 Subject: [PATCH 118/163] Update develop after v2.23.1 --- cvat-cli/requirements/base.txt | 2 +- cvat-cli/src/cvat_cli/version.py | 2 +- cvat-sdk/gen/generate.sh | 2 +- cvat/__init__.py | 2 +- cvat/schema.yml | 2 +- docker-compose.yml | 20 ++++++++++---------- helm-chart/values.yaml | 4 ++-- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cvat-cli/requirements/base.txt b/cvat-cli/requirements/base.txt index 8793644b6339..ed0e7619253b 100644 --- a/cvat-cli/requirements/base.txt +++ b/cvat-cli/requirements/base.txt @@ -1,3 +1,3 @@ -cvat-sdk~=2.23.1 +cvat-sdk~=2.23.2 Pillow>=10.3.0 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/cvat-cli/src/cvat_cli/version.py b/cvat-cli/src/cvat_cli/version.py index c642e25a75ea..a8db20fc3a49 100644 --- a/cvat-cli/src/cvat_cli/version.py +++ b/cvat-cli/src/cvat_cli/version.py @@ -1 +1 @@ -VERSION = "2.23.1" +VERSION = "2.23.2" diff --git a/cvat-sdk/gen/generate.sh b/cvat-sdk/gen/generate.sh index de41d2f680cb..1131ef70d1c9 100755 --- a/cvat-sdk/gen/generate.sh +++ b/cvat-sdk/gen/generate.sh @@ -8,7 +8,7 @@ set -e GENERATOR_VERSION="v6.0.1" -VERSION="2.23.1" +VERSION="2.23.2" LIB_NAME="cvat_sdk" LAYER1_LIB_NAME="${LIB_NAME}/api_client" DST_DIR="$(cd "$(dirname -- "$0")/.." && pwd)" diff --git a/cvat/__init__.py b/cvat/__init__.py index 48299beff13a..8ebb57409985 100644 --- a/cvat/__init__.py +++ b/cvat/__init__.py @@ -4,6 +4,6 @@ from cvat.utils.version import get_version -VERSION = (2, 23, 1, "final", 0) +VERSION = (2, 23, 2, "alpha", 0) __version__ = get_version(VERSION) diff --git a/cvat/schema.yml b/cvat/schema.yml index 4b261f66c905..38e74d7936e0 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: CVAT REST API - version: 2.23.1 + version: 2.23.2 description: REST API for Computer Vision Annotation Tool (CVAT) termsOfService: https://www.google.com/policies/terms/ contact: diff --git a/docker-compose.yml b/docker-compose.yml index b00ff6ba3ac6..a921b70cbf9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,7 @@ services: cvat_server: container_name: cvat_server - image: cvat/server:${CVAT_VERSION:-v2.23.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: <<: *backend-deps @@ -113,7 +113,7 @@ services: cvat_utils: container_name: cvat_utils - image: cvat/server:${CVAT_VERSION:-v2.23.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -130,7 +130,7 @@ services: cvat_worker_import: container_name: cvat_worker_import - image: cvat/server:${CVAT_VERSION:-v2.23.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -146,7 +146,7 @@ services: cvat_worker_export: container_name: cvat_worker_export - image: cvat/server:${CVAT_VERSION:-v2.23.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -162,7 +162,7 @@ services: cvat_worker_annotation: container_name: cvat_worker_annotation - image: cvat/server:${CVAT_VERSION:-v2.23.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -178,7 +178,7 @@ services: cvat_worker_webhooks: container_name: cvat_worker_webhooks - image: cvat/server:${CVAT_VERSION:-v2.23.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -194,7 +194,7 @@ services: cvat_worker_quality_reports: container_name: cvat_worker_quality_reports - image: cvat/server:${CVAT_VERSION:-v2.23.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -210,7 +210,7 @@ services: cvat_worker_analytics_reports: container_name: cvat_worker_analytics_reports - image: cvat/server:${CVAT_VERSION:-v2.23.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -226,7 +226,7 @@ services: cvat_worker_chunks: container_name: cvat_worker_chunks - image: cvat/server:${CVAT_VERSION:-v2.23.1} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -242,7 +242,7 @@ services: cvat_ui: container_name: cvat_ui - image: cvat/ui:${CVAT_VERSION:-v2.23.1} + image: cvat/ui:${CVAT_VERSION:-dev} restart: always depends_on: - cvat_server diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index be05c90f1960..ae0180efd972 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -139,7 +139,7 @@ cvat: additionalVolumeMounts: [] replicas: 1 image: cvat/server - tag: v2.23.1 + tag: dev imagePullPolicy: Always permissionFix: enabled: true @@ -162,7 +162,7 @@ cvat: frontend: replicas: 1 image: cvat/ui - tag: v2.23.1 + tag: dev imagePullPolicy: Always labels: {} # test: test From 094c62d7c52ac5bb230b22daabec2d5d88de1be7 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Mon, 9 Dec 2024 11:45:57 +0200 Subject: [PATCH 119/163] Remove the `ready_for_review` trigger from the Docs workflow (#8788) This workflow runs for draft PRs, so there's no need to run it again when a PR is marked as ready. --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }} From 9a08a0c19e0ebbb523bf1bbae15b90ecd95486e3 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Tue, 10 Dec 2024 17:31:51 +0200 Subject: [PATCH 120/163] Nuclio functions in cvat network (#8777) --- docker-compose.yml | 2 ++ serverless/deploy_cpu.sh | 6 +++--- serverless/deploy_gpu.sh | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a921b70cbf9f..c13cb5bab74f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: MIT +name: cvat + x-backend-env: &backend-env CVAT_POSTGRES_HOST: cvat_db CVAT_REDIS_INMEM_HOST: cvat_redis_inmem diff --git a/serverless/deploy_cpu.sh b/serverless/deploy_cpu.sh index 9f37ea020a6b..1e3834edbd99 100755 --- a/serverless/deploy_cpu.sh +++ b/serverless/deploy_cpu.sh @@ -26,9 +26,9 @@ do echo "Deploying $func_rel_path function..." nuctl deploy --project-name cvat --path "$func_root" \ --file "$func_config" --platform local \ - --env CVAT_REDIS_HOST=$(echo ${CVAT_REDIS_INMEM_HOST:-cvat_redis_ondisk}) \ - --env CVAT_REDIS_PORT=$(echo ${CVAT_REDIS_INMEM_PORT:-6666}) \ - --env CVAT_REDIS_PASSWORD=$(echo ${CVAT_REDIS_INMEM_PASSWORD}) + --env CVAT_FUNCTIONS_REDIS_HOST=cvat_redis_ondisk \ + --env CVAT_FUNCTIONS_REDIS_PORT=6666 \ + --platform-config '{"attributes": {"network": "cvat_cvat"}}' done nuctl get function --platform local diff --git a/serverless/deploy_gpu.sh b/serverless/deploy_gpu.sh index 9c8e1515b73b..49d71ff352c3 100755 --- a/serverless/deploy_gpu.sh +++ b/serverless/deploy_gpu.sh @@ -18,9 +18,9 @@ do echo "Deploying $func_rel_path function..." nuctl deploy --project-name cvat --path "$func_root" \ --file "$func_config" --platform local \ - --env CVAT_REDIS_HOST=$(echo ${CVAT_REDIS_INMEM_HOST:-cvat_redis_ondisk}) \ - --env CVAT_REDIS_PORT=$(echo ${CVAT_REDIS_INMEM_PORT:-6666}) \ - --env CVAT_REDIS_PASSWORD=$(echo ${CVAT_REDIS_INMEM_PASSWORD}) + --env CVAT_FUNCTIONS_REDIS_HOST=cvat_redis_ondisk \ + --env CVAT_FUNCTIONS_REDIS_PORT=6666 \ + --platform-config '{"attributes": {"network": "cvat_cvat"}}' done nuctl get function --platform local From 90f12babfe3799021ba3a5cf782d0d3b0cdde431 Mon Sep 17 00:00:00 2001 From: Oleg Valiulin Date: Tue, 10 Dec 2024 15:59:35 +0000 Subject: [PATCH 121/163] Fix instructions for running tests (#8808) Update instructions for running tests: - for running docker without sudo, the user has to be in docker group (as per Docker's [Linux post-install](https://docs.docker.com/engine/install/linux-postinstall/)) - the referenced guide already has instructions for installing the local packages, no need to duplicate it here --- tests/python/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/python/README.md b/tests/python/README.md index 74373153085b..3a7b246c5508 100644 --- a/tests/python/README.md +++ b/tests/python/README.md @@ -20,13 +20,15 @@ the server calling REST API directly (as it done by users). ## How to run? **Initial steps** +1. On Debian/Ubuntu, make sure that your `$USER` is in `docker` group: + ```shell + sudo usermod -aG docker $USER + ``` 1. Follow [this guide](../../site/content/en/docs/api_sdk/sdk/developer-guide.md) to prepare `cvat-sdk` and `cvat-cli` source code 1. Install all necessary requirements before running REST API tests: - ``` + ```shell pip install -r ./tests/python/requirements.txt - pip install -e ./cvat-sdk - pip install -e ./cvat-cli ``` 1. Stop any other CVAT containers which you run previously. They keep ports which are used by containers for the testing system. From 15fd273ee3bd385ba8be014c481d4a12a7a1f9ad Mon Sep 17 00:00:00 2001 From: Oleg Valiulin Date: Tue, 10 Dec 2024 16:00:32 +0000 Subject: [PATCH 122/163] Update pip install command for local packages (#8809) ```pip install cvat-sdk/``` command is error-prone, the user can easily forget the slash and start downloading from PyPI, which is not required. To avoid this, it's better to write explicitly with current directory in mind --- site/content/en/docs/api_sdk/sdk/developer-guide.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/content/en/docs/api_sdk/sdk/developer-guide.md b/site/content/en/docs/api_sdk/sdk/developer-guide.md index 65047488df34..ddb62344eab3 100644 --- a/site/content/en/docs/api_sdk/sdk/developer-guide.md +++ b/site/content/en/docs/api_sdk/sdk/developer-guide.md @@ -32,15 +32,15 @@ the repository. To get the full package, one need to generate missing package fi 1. Install the packages: ```bash - pip install cvat-sdk/ - pip install cvat-cli/ + pip install ./cvat-sdk + pip install ./cvat-cli ``` If you want to edit package files, install them with `-e`: ```bash - pip install -e cvat-sdk/ - pip install -e cvat-cli/ + pip install -e ./cvat-sdk + pip install -e ./cvat-cli ``` ## How to edit templates From 17016dedeb7249178b129a63dc8cd08311e7b5c2 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Tue, 10 Dec 2024 18:50:09 +0200 Subject: [PATCH 123/163] Added support for boolean parameters in annotations actions (#8798) --- ...0241209_110126_sekachev.bs_support_boolean.md | 4 ++++ cvat-core/src/annotations-actions/base-action.ts | 1 + .../annotations-actions-modal.tsx | 16 ++++++++++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 changelog.d/20241209_110126_sekachev.bs_support_boolean.md diff --git a/changelog.d/20241209_110126_sekachev.bs_support_boolean.md b/changelog.d/20241209_110126_sekachev.bs_support_boolean.md new file mode 100644 index 000000000000..4e0e98e1aba2 --- /dev/null +++ b/changelog.d/20241209_110126_sekachev.bs_support_boolean.md @@ -0,0 +1,4 @@ +### Added + +- Support for boolean parameters in annotations actions + () diff --git a/cvat-core/src/annotations-actions/base-action.ts b/cvat-core/src/annotations-actions/base-action.ts index 3246261d2c9a..2ec2148b24c7 100644 --- a/cvat-core/src/annotations-actions/base-action.ts +++ b/cvat-core/src/annotations-actions/base-action.ts @@ -9,6 +9,7 @@ import { Job, Task } from '../session'; export enum ActionParameterType { SELECT = 'select', NUMBER = 'number', + CHECKBOX = 'checkbox', } // For SELECT values should be a list of possible options diff --git a/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx b/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx index f33dd9bf231a..fc98e3c27fa8 100644 --- a/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx +++ b/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx @@ -25,10 +25,12 @@ import { getCVATStore } from 'cvat-store'; import { BaseCollectionAction, BaseAction, Job, getCore, ObjectState, + ActionParameterType, } from 'cvat-core-wrapper'; import { Canvas } from 'cvat-canvas-wrapper'; import { fetchAnnotationsAsync } from 'actions/annotation-actions'; import { clamp } from 'utils/math'; +import { Switch } from 'antd/lib'; const core = getCore(); @@ -248,7 +250,7 @@ function ActionParameterComponent(props: ActionParameterProps & { onChange: (val const computedValues = typeof values === 'function' ? values({ instance: job }) : values; - if (type === 'select') { + if (type === ActionParameterType.SELECT) { return (