From a6bc9e29fb63e96e1dff3fdb479cf2c26c62ffae Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 9 Jul 2024 12:30:00 +0300 Subject: [PATCH 001/227] Add basic implementation for task creation with honeypots --- cvat-sdk/cvat_sdk/core/proxies/tasks.py | 1 + ...lidationparams_validationimage_and_more.py | 81 ++++++++++ .../migrations/0080_image_is_placeholder.py | 18 +++ cvat/apps/engine/models.py | 36 ++++- cvat/apps/engine/serializers.py | 96 +++++++++++- cvat/apps/engine/task.py | 145 +++++++++++++++--- cvat/apps/engine/views.py | 2 +- 7 files changed, 354 insertions(+), 25 deletions(-) create mode 100644 cvat/apps/engine/migrations/0079_validationparams_validationimage_and_more.py create mode 100644 cvat/apps/engine/migrations/0080_image_is_placeholder.py diff --git a/cvat-sdk/cvat_sdk/core/proxies/tasks.py b/cvat-sdk/cvat_sdk/core/proxies/tasks.py index 2a2a33f6cff9..6271e5f25fd4 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/tasks.py +++ b/cvat-sdk/cvat_sdk/core/proxies/tasks.py @@ -96,6 +96,7 @@ def upload_data( "filename_pattern", "cloud_storage_id", "server_files_exclude", + "validation_params", ], ) ) diff --git a/cvat/apps/engine/migrations/0079_validationparams_validationimage_and_more.py b/cvat/apps/engine/migrations/0079_validationparams_validationimage_and_more.py new file mode 100644 index 000000000000..4320a74f98e0 --- /dev/null +++ b/cvat/apps/engine/migrations/0079_validationparams_validationimage_and_more.py @@ -0,0 +1,81 @@ +# Generated by Django 4.2.13 on 2024-07-08 13:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0078_alter_cloudstorage_credentials"), + ] + + operations = [ + migrations.CreateModel( + name="ValidationParams", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "mode", + models.CharField( + choices=[("gt", "GT"), ("gt_pool", "GT_POOL")], max_length=32 + ), + ), + ( + "frame_selection_method", + models.CharField( + choices=[ + ("random_uniform", "RANDOM_UNIFORM"), + ("manual", "MANUAL"), + ], + max_length=32, + ), + ), + ("random_seed", models.IntegerField(null=True)), + ("frames_count", models.IntegerField(null=True)), + ("frames_percent", models.FloatField(null=True)), + ("frames_per_job_count", models.IntegerField(null=True)), + ("frames_per_job_percent", models.FloatField(null=True)), + ], + ), + migrations.CreateModel( + name="ValidationImage", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("path", models.CharField(default="", max_length=1024)), + ( + "validation_params", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="engine.validationparams", + ), + ), + ], + ), + migrations.AddField( + model_name="data", + name="validation_params", + field=models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="task_data", + to="engine.validationparams", + ), + ), + ] diff --git a/cvat/apps/engine/migrations/0080_image_is_placeholder.py b/cvat/apps/engine/migrations/0080_image_is_placeholder.py new file mode 100644 index 000000000000..fb6c3389c4ba --- /dev/null +++ b/cvat/apps/engine/migrations/0080_image_is_placeholder.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-07-08 16:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0079_validationparams_validationimage_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="image", + name="is_placeholder", + field=models.BooleanField(default=False), + ), + ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 907937382a1b..2b16b7a5e2f8 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -1,5 +1,5 @@ # Copyright (C) 2018-2022 Intel Corporation -# Copyright (C) 2022-2023 CVAT.ai Corporation +# Copyright (C) 2022-2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -216,6 +216,36 @@ class FloatArrayField(AbstractArrayField): class IntArrayField(AbstractArrayField): converter = int +class ValidationMode(str, Enum): + GT = "gt" + GT_POOL = "gt_pool" + + @classmethod + def choices(cls): + return tuple((x.value, x.name) for x in cls) + + def __str__(self): + return self.value + +class ValidationParams(models.Model): + mode = models.CharField(max_length=32, choices=ValidationMode.choices()) + + # TODO: consider other storage options and ways to pass the parameters + frame_selection_method = models.CharField( + max_length=32, choices=JobFrameSelectionMethod.choices() + ) + random_seed = models.IntegerField(null=True) + + frames: list[ValidationImage] + frames_count = models.IntegerField(null=True) + frames_percent = models.FloatField(null=True) + frames_per_job_count = models.IntegerField(null=True) + frames_per_job_percent = models.FloatField(null=True) + +class ValidationImage(models.Model): + validation_params = models.ForeignKey(ValidationParams, on_delete=models.CASCADE) + path = models.CharField(max_length=1024, default='') + class Data(models.Model): chunk_size = models.PositiveIntegerField(null=True) size = models.PositiveIntegerField(default=0) @@ -232,6 +262,9 @@ class Data(models.Model): cloud_storage = models.ForeignKey('CloudStorage', on_delete=models.SET_NULL, null=True, related_name='data') sorting_method = models.CharField(max_length=15, choices=SortingMethod.choices(), default=SortingMethod.LEXICOGRAPHICAL) deleted_frames = IntArrayField(store_sorted=True, unique_values=True) + validation_params = models.OneToOneField( + 'ValidationParams', on_delete=models.CASCADE, null=True, related_name="task_data" + ) class Meta: default_permissions = () @@ -311,6 +344,7 @@ class Image(models.Model): frame = models.PositiveIntegerField() width = models.PositiveIntegerField() height = models.PositiveIntegerField() + is_placeholder = models.BooleanField(default=False) class Meta: default_permissions = () diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 235e6d700dcd..27eda70562de 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1,5 +1,5 @@ # Copyright (C) 2019-2022 Intel Corporation -# Copyright (C) 2022-2023 CVAT.ai Corporation +# Copyright (C) 2022-2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -891,6 +891,75 @@ def __init__(self, *args, **kwargs): kwargs.setdefault('help_text', textwrap.dedent(__class__.__doc__)) super().__init__(*args, **kwargs) +class ValidationParamsSerializer(serializers.Serializer): + mode = serializers.ChoiceField(choices=models.ValidationMode.choices(), required=True) + frame_selection_method = serializers.ChoiceField(choices=models.JobFrameSelectionMethod.choices(), required=True) + frames = serializers.ListSerializer( + child=serializers.CharField(max_length=1024), default=[], required=False, allow_null=True + ) + frames_count = serializers.IntegerField(required=False, allow_null=True) + frames_percent = serializers.FloatField(required=False, allow_null=True) + random_seed = serializers.IntegerField(required=False, allow_null=True) + frames_per_job_count = serializers.IntegerField(required=False, allow_null=True) + frames_per_job_percent = serializers.FloatField(required=False, allow_null=True) + + + # def validate(self, attrs): + # if attrs['mode'] == models.ValidationMode.GT: + # if not ( + # ( + # attrs['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_UNIFORM + # and ( + # attrs.get('frames_count') is not None + # or attrs.get('frames_percent') is not None + # ) + # and not attrs.get('frames') + # ) + # ^ + # ( + # ['frame_selection_method'] == models.JobFrameSelectionMethod.MANUAL + # and not attrs.get('frames') + # and attrs.get('frames_count') is None + # and attrs.get('frames_percent') is None + # ) + # ): + # return super().validate(attrs) + + + @transaction.atomic + def create(self, validated_data): + frames = validated_data.pop('frames', None) + + instance = models.ValidationParams(**validated_data) + instance.save() + + if frames: + models.ValidationImage.objects.bulk_create( + { "validation_params_id": instance.id, "path": frame } + for frame in frames + ) + + return instance + + @transaction.atomic + def update(self, instance, validated_data): + frames = validated_data.pop('frames', None) + + for k, v in validated_data.items(): + setattr(instance, k, v) + instance.save() + + if frames: + if instance.frames.count(): + for db_frame in instance.frames.all(): + db_frame.delete() + + models.ValidationImage.objects.bulk_create( + { "validation_params_id": instance.id, "path": frame } + for frame in frames + ) + + return instance class DataSerializer(serializers.ModelSerializer): """ @@ -978,6 +1047,7 @@ class DataSerializer(serializers.ModelSerializer): pass the list of file names in the required order. """.format(models.SortingMethod.PREDEFINED)) ) + validation_params = ValidationParamsSerializer(allow_null=True, required=False) class Meta: model = models.Data @@ -987,7 +1057,7 @@ class Meta: 'use_zip_chunks', 'server_files_exclude', 'cloud_storage_id', 'use_cache', 'copy_data', 'storage_method', 'storage', 'sorting_method', 'filename_pattern', - 'job_file_mapping', 'upload_file_order', + 'job_file_mapping', 'upload_file_order', 'validation_params' ) extra_kwargs = { 'chunk_size': { 'help_text': "Maximum number of frames per chunk" }, @@ -1059,9 +1129,27 @@ def create(self, validated_data): def update(self, instance, validated_data): files = self._pop_data(validated_data) + validation_params = validated_data.pop('validation_params', None) for key, value in validated_data.items(): setattr(instance, key, value) self._create_files(instance, files) + + if validation_params: + db_validation_params = instance.validation_params + validation_params_serializer = ValidationParamsSerializer( + instance=db_validation_params, data=validation_params + ) + if not db_validation_params: + db_validation_params = validation_params_serializer.create( + validation_params + ) + else: + db_validation_params = validation_params_serializer.update( + db_validation_params, validation_params + ) + + instance.validation_params = db_validation_params + instance.save() return instance @@ -1110,6 +1198,7 @@ class TaskReadSerializer(serializers.ModelSerializer): source_storage = StorageSerializer(required=False, allow_null=True) jobs = JobsSummarySerializer(url_filter_key='task_id', source='segment_set') labels = LabelsSummarySerializer(source='*') + validation_mode = serializers.CharField(source='validation_params.mode', required=False, allow_null=True) class Meta: model = models.Task @@ -1118,6 +1207,7 @@ class Meta: 'status', 'data_chunk_size', 'data_compressed_chunk_type', 'guide_id', 'data_original_chunk_type', 'size', 'image_quality', 'data', 'dimension', 'subset', 'organization', 'target_storage', 'source_storage', 'jobs', 'labels', + 'validation_mode' ) read_only_fields = fields extra_kwargs = { @@ -1138,7 +1228,7 @@ class Meta: model = models.Task fields = ('url', 'id', 'name', 'project_id', 'owner_id', 'assignee_id', 'bug_tracker', 'overlap', 'segment_size', 'labels', 'subset', - 'target_storage', 'source_storage', + 'target_storage', 'source_storage' ) write_once_fields = ('overlap', 'segment_size') diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index c44f01e1f354..194ec6761b8e 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1,16 +1,17 @@ # Copyright (C) 2018-2022 Intel Corporation -# Copyright (C) 2022-2023 CVAT.ai Corporation +# Copyright (C) 2022-2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT import itertools import fnmatch import os -from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Union, Iterable -from rest_framework.serializers import ValidationError import rq import re import shutil +from copy import deepcopy +from rest_framework.serializers import ValidationError +from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Sequence, Union, Iterable from urllib import parse as urlparse from urllib import request as urlrequest import django_rq @@ -19,6 +20,7 @@ from django.conf import settings from django.db import transaction +from django.forms.models import model_to_dict from django.http import HttpRequest from datetime import datetime, timezone from pathlib import Path @@ -70,6 +72,8 @@ def create( class SegmentParams(NamedTuple): start_frame: int stop_frame: int + type: models.SegmentType = models.SegmentType.RANGE + frames: Optional[Sequence[int]] = [] class SegmentsParams(NamedTuple): segments: Iterator[SegmentParams] @@ -126,10 +130,14 @@ def _segments(): # It is assumed here that files are already saved ordered in the task # Here we just need to create segments by the job sizes start_frame = 0 - for jf in job_file_mapping: - segment_size = len(jf) + for job_files in job_file_mapping: + segment_size = len(job_files) stop_frame = start_frame + segment_size - 1 - yield SegmentParams(start_frame, stop_frame) + yield SegmentParams( + start_frame=start_frame, + stop_frame=stop_frame, + type=models.SegmentType.RANGE, + ) start_frame = stop_frame + 1 @@ -152,31 +160,39 @@ def _segments(): ) segments = ( - SegmentParams(start_frame, min(start_frame + segment_size - 1, data_size - 1)) + SegmentParams( + start_frame=start_frame, + stop_frame=min(start_frame + segment_size - 1, data_size - 1), + type=models.SegmentType.RANGE + ) for start_frame in range(0, data_size - overlap, segment_size - overlap) ) return SegmentsParams(segments, segment_size, overlap) -def _save_task_to_db(db_task: models.Task, *, job_file_mapping: Optional[JobFileMapping] = None): - job = rq.get_current_job() - job.meta['status'] = 'Task is being saved in database' - job.save_meta() +def _save_task_to_db( + db_task: models.Task, + *, + job_file_mapping: Optional[JobFileMapping] = None, +): + rq_job = rq.get_current_job() + rq_job.meta['status'] = 'Task is being saved in database' + rq_job.save_meta() segments, segment_size, overlap = _get_task_segment_data( - db_task=db_task, job_file_mapping=job_file_mapping + db_task=db_task, job_file_mapping=job_file_mapping, ) db_task.segment_size = segment_size db_task.overlap = overlap - for segment_idx, (start_frame, stop_frame) in enumerate(segments): - slogger.glob.info("New segment for task #{}: idx = {}, start_frame = {}, \ - stop_frame = {}".format(db_task.id, segment_idx, start_frame, stop_frame)) + for segment_idx, segment_params in enumerate(segments): + slogger.glob.info( + "New segment for task #{task_id}: idx = {segment_idx}, start_frame = {start_frame}, \ + stop_frame = {stop_frame}".format( + task_id=db_task.id, segment_idx=segment_idx, **segment_params._asdict() + )) - db_segment = models.Segment() - db_segment.task = db_task - db_segment.start_frame = start_frame - db_segment.stop_frame = stop_frame + db_segment = models.Segment(task=db_task, **segment_params._asdict()) db_segment.save() db_job = models.Job(segment=db_segment) @@ -315,6 +331,31 @@ def _validate_job_file_mapping( return job_file_mapping +def _validate_validation_params( + db_task: models.Task, data: Dict[str, Any] +) -> Optional[dict[str, Any]]: + validation_params = data.get('validation_params', {}) + if not validation_params: + return None + + if validation_params['mode'] != models.ValidationMode.GT_POOL: + return validation_params + + if data.get('sorting_method', db_task.data.sorting_method) != models.SortingMethod.RANDOM: + raise ValidationError("validation mode '{}' can only be used with '{}' sorting".format( + models.ValidationMode.GT_POOL.value, + models.SortingMethod.RANDOM.value, + )) + + for incompatible_key in ['job_file_mapping', 'overlap']: + if data.get(incompatible_key): + raise ValidationError("validation mode '{}' cannot be used with '{}'".format( + models.ValidationMode.GT_POOL.value, + incompatible_key, + )) + + return validation_params + def _validate_manifest( manifests: List[str], root_dir: Optional[str], @@ -522,6 +563,7 @@ def _create_thread( slogger.glob.info("create task #{}".format(db_task.id)) job_file_mapping = _validate_job_file_mapping(db_task, data) + validation_params = _validate_validation_params(db_task, data) db_data = db_task.data upload_dir = db_data.get_upload_dirname() if db_data.storage != models.StorageChoice.SHARE else settings.SHARE_ROOT @@ -962,7 +1004,7 @@ def update_progress(progress): video_path = "" video_size = (0, 0) - db_images = [] + db_images: list[models.Image] = [] if settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE: for media_type, media_files in media.items(): @@ -1136,6 +1178,55 @@ def process_results(img_meta: list[tuple[str, int, tuple[int, int]]]): while not futures.empty(): process_results(futures.get().result()) + if validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: + if db_task.mode != 'annotation': + raise ValidationError("gt pool can only be used with 'annotation' mode tasks") + + # TODO: handle other input variants + seed = validation_params["random_seed"] + frames_count = validation_params["frames_count"] + frames_per_job_count = validation_params["frames_per_job_count"] + + # 1. select pool frames + # The RNG backend must not change to yield reproducible results, + # so here we specify it explicitly + from numpy import random + rng = random.Generator(random.MT19937(seed=seed)) + + all_frames = range(len(db_images)) + pool_frames: list[int] = rng.choice( + all_frames, size=frames_count, shuffle=False, replace=False + ).tolist() + non_pool_frames = set(all_frames).difference(pool_frames) + + # 2. distribute pool frames + from datumaro.util import take_by + + job_file_mapping = [] + new_db_images = [] + frame_id_map = {} + for job_frames in take_by(non_pool_frames, count=frames_per_job_count): + job_validation_frames = rng.choice(pool_frames, size=frames_per_job_count, replace=False) + job_frames += job_validation_frames.tolist() + + random.shuffle(job_frames) # don't use the same rng + + job_images = [] + for job_frame in job_frames: + # Insert placeholder frames into the frame sequence and shift frame ids + image = models.Image(data=db_data, **deepcopy(model_to_dict(db_images[job_frame], exclude=["data"]))) + image.frame = frame_id_map.setdefault(job_frame, len(new_db_images)) + + if job_frame in job_validation_frames: + image.is_placeholder = True + + job_images.append(image) + new_db_images.append(image) + + job_file_mapping.append(job_images) + + pool_frames = [frame_id_map[i] for i in pool_frames if i in frame_id_map] + if db_task.mode == 'annotation': models.Image.objects.bulk_create(db_images) created_images = models.Image.objects.filter(data_id=db_data.id) @@ -1162,3 +1253,17 @@ def process_results(img_meta: list[tuple[str, int, tuple[int, int]]]): slogger.glob.info("Found frames {} for Data #{}".format(db_data.size, db_data.id)) _save_task_to_db(db_task, job_file_mapping=job_file_mapping) + + if validation_params: + db_gt_segment = models.Segment( + task=db_task, + start_frame=0, + stop_frame=db_data.stop_frame, + frames=pool_frames, + type=models.SegmentType.SPECIFIC_FRAMES, + ) + db_gt_segment.save() + + db_gt_job = models.Job(segment=db_gt_segment, type=models.JobType.GROUND_TRUTH) + db_gt_job.save() + db_gt_job.make_dirs() diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 880c78c00898..20862e8673f1 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -1102,7 +1102,7 @@ def _handle_upload_data(request): # Create a temporary copy of the parameters we will try to create the task with data = copy(serializer.data) - for optional_field in ['job_file_mapping', 'server_files_exclude']: + for optional_field in ['job_file_mapping', 'server_files_exclude', 'validation_params']: if optional_field in serializer.validated_data: data[optional_field] = serializer.validated_data[optional_field] From dbb39fdfdd69026ce0b0a359f62335dfdf09d880 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 9 Jul 2024 13:45:41 +0300 Subject: [PATCH 002/227] Update api schema --- cvat/schema.yml | 54 +++++++++++++++++++++++++++++++++++++++++-- cvat/settings/base.py | 2 ++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/cvat/schema.yml b/cvat/schema.yml index bf25130a5b67..70887eef87d7 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -7443,6 +7443,11 @@ components: If you want to send files in an arbitrary order and reorder them afterwards on the server, pass the list of file names in the required order. + validation_params: + allOf: + - $ref: '#/components/schemas/ValidationParamsRequest' + writeOnly: true + nullable: true required: - image_quality DataResponse: @@ -7730,7 +7735,7 @@ components: - name - related_files - width - FrameSelectionMethodEnum: + FrameSelectionMethod: enum: - random_uniform - manual @@ -8138,7 +8143,7 @@ components: task_id: type: integer frame_selection_method: - $ref: '#/components/schemas/FrameSelectionMethodEnum' + $ref: '#/components/schemas/FrameSelectionMethod' frame_count: type: integer minimum: 0 @@ -10427,6 +10432,9 @@ components: $ref: '#/components/schemas/JobsSummary' labels: $ref: '#/components/schemas/LabelsSummary' + validation_mode: + type: string + nullable: true required: - jobs - labels @@ -10656,6 +10664,48 @@ components: maxLength: 150 required: - username + ValidationMode: + enum: + - gt + - gt_pool + type: string + description: |- + * `gt` - GT + * `gt_pool` - GT_POOL + ValidationParamsRequest: + type: object + properties: + mode: + $ref: '#/components/schemas/ValidationMode' + frame_selection_method: + $ref: '#/components/schemas/FrameSelectionMethod' + frames: + type: array + items: + type: string + minLength: 1 + nullable: true + default: [] + frames_count: + type: integer + nullable: true + frames_percent: + type: number + format: double + nullable: true + random_seed: + type: integer + nullable: true + frames_per_job_count: + type: integer + nullable: true + frames_per_job_percent: + type: number + format: double + nullable: true + required: + - frame_selection_method + - mode WebhookContentType: enum: - application/json diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 1f4b3592d696..4cb3547a5006 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -637,6 +637,8 @@ class CVAT_QUEUES(Enum): 'WebhookType': 'cvat.apps.webhooks.models.WebhookTypeChoice', 'WebhookContentType': 'cvat.apps.webhooks.models.WebhookContentTypeChoice', 'RequestStatus': 'cvat.apps.engine.serializers.RequestStatus', + 'ValidationMode': 'cvat.apps.engine.models.ValidationMode', + 'FrameSelectionMethod': 'cvat.apps.engine.models.JobFrameSelectionMethod', }, # Coercion of {pk} to {id} is controlled by SCHEMA_COERCE_PATH_PK. Additionally, From 675c160fc5b72784b3366bf7bdd38c928b5e42b7 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 11 Jul 2024 10:15:55 +0300 Subject: [PATCH 003/227] Implement job creation, add job honeypot update endpoint, refactor task creation (put chunk creation into the end), need to update chunk generation approach to to per job --- cvat/apps/engine/cache.py | 23 +- .../migrations/0081_image_real_frame_id.py | 18 + cvat/apps/engine/models.py | 7 +- cvat/apps/engine/serializers.py | 47 +- cvat/apps/engine/task.py | 415 ++++++++++-------- cvat/apps/engine/utils.py | 20 +- cvat/apps/engine/views.py | 150 ++++++- 7 files changed, 484 insertions(+), 196 deletions(-) create mode 100644 cvat/apps/engine/migrations/0081_image_real_frame_id.py diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 2603c2fd5a13..60b5d4f03a7f 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -1,5 +1,5 @@ # Copyright (C) 2020-2022 Intel Corporation -# Copyright (C) 2022-2023 CVAT.ai Corporation +# Copyright (C) 2022-2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -33,7 +33,7 @@ ZipCompressedChunkWriter) from cvat.apps.engine.mime_types import mimetypes from cvat.apps.engine.models import (DataChoice, DimensionType, Job, Image, - StorageChoice, CloudStorage) + StorageChoice, CloudStorage, Data) from cvat.apps.engine.utils import md5_hash, preload_images from utils.dataset_manifest import ImageManifestManager @@ -76,14 +76,31 @@ def create_item(): return item[0], item[1] + def _delete_cache_item(self, key: str): + try: + 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 _make_task_chunk_key(self, chunk_number: int, quality: str, db_data: Data) -> str: + return f'{db_data.id}_{chunk_number}_{quality}' + def get_task_chunk_data_with_mime(self, chunk_number, quality, db_data): item = self._get_or_set_cache_item( - key=f'{db_data.id}_{chunk_number}_{quality}', + key=self._make_task_chunk_key( + chunk_number=chunk_number, quality=quality, db_data=db_data + ), create_function=lambda: self._prepare_task_chunk(db_data, quality, chunk_number), ) return item + def remove_task_chunk(self, chunk_number: str, quality: str, db_data: Data): + self._delete_cache_item(self._make_task_chunk_key( + chunk_number=chunk_number, quality=quality, db_data=db_data + )) + def get_selective_job_chunk_data_with_mime(self, chunk_number, quality, job): item = self._get_or_set_cache_item( key=f'job_{job.id}_{chunk_number}_{quality}', diff --git a/cvat/apps/engine/migrations/0081_image_real_frame_id.py b/cvat/apps/engine/migrations/0081_image_real_frame_id.py new file mode 100644 index 000000000000..f0e660713c18 --- /dev/null +++ b/cvat/apps/engine/migrations/0081_image_real_frame_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-07-10 10:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0080_image_is_placeholder"), + ] + + operations = [ + migrations.AddField( + model_name="image", + name="real_frame_id", + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 2b16b7a5e2f8..a7ad7f159193 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -11,7 +11,7 @@ import uuid from enum import Enum from functools import cached_property -from typing import Any, Dict, Optional, Sequence +from typing import Any, Collection, Dict, Optional from django.conf import settings from django.contrib.auth.models import User @@ -262,7 +262,7 @@ class Data(models.Model): cloud_storage = models.ForeignKey('CloudStorage', on_delete=models.SET_NULL, null=True, related_name='data') sorting_method = models.CharField(max_length=15, choices=SortingMethod.choices(), default=SortingMethod.LEXICOGRAPHICAL) deleted_frames = IntArrayField(store_sorted=True, unique_values=True) - validation_params = models.OneToOneField( + validation_params: Optional[ValidationParams] = models.OneToOneField( 'ValidationParams', on_delete=models.CASCADE, null=True, related_name="task_data" ) @@ -345,6 +345,7 @@ class Image(models.Model): width = models.PositiveIntegerField() height = models.PositiveIntegerField() is_placeholder = models.BooleanField(default=False) + real_frame_id = models.PositiveIntegerField(default=0) class Meta: default_permissions = () @@ -606,7 +607,7 @@ def frame_count(self) -> int: return len(self.frame_set) @property - def frame_set(self) -> Sequence[int]: + def frame_set(self) -> Collection[int]: data = self.task.data data_start_frame = data.start_frame data_stop_frame = data.stop_frame diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 27eda70562de..3d9270111de2 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -154,6 +154,9 @@ def __init__(self, *, model=models.Job, url_filter_key, **kwargs): super().__init__(model=model, url_filter_key=url_filter_key, **kwargs) +MAX_FILENAME_LENGTH = 1024 + + class TasksSummarySerializer(_CollectionSummarySerializer): pass @@ -787,6 +790,31 @@ class Meta: fields = ('url', 'id', 'assignee', 'status', 'stage', 'state', 'type') read_only_fields = fields +class JobHoneypotWriteSerializer(serializers.Serializer): + frame_selection_method = serializers.ChoiceField( + choices=models.JobFrameSelectionMethod.choices(), required=True + ) + frames = serializers.ListSerializer( + child=serializers.CharField(max_length=MAX_FILENAME_LENGTH), + default=[], required=False, allow_null=True + ) + + def validate(self, attrs): + frame_selection_method = attrs["frame_selection_method"] + if frame_selection_method == models.JobFrameSelectionMethod.MANUAL: + required_field_name = "frames" + if required_field_name not in attrs: + raise serializers.ValidationError("'{}' must be set".format(required_field_name)) + elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: + pass + else: + assert False + + return super().validate(attrs) + +class JobHoneypotReadSerializer(serializers.Serializer): + frames = serializers.ListSerializer(child=serializers.IntegerField(), allow_empty=True) + class SegmentSerializer(serializers.ModelSerializer): jobs = SimpleJobSerializer(many=True, source='job_set') frames = serializers.ListSerializer(child=serializers.IntegerField(), allow_empty=True) @@ -860,7 +888,9 @@ class JobFiles(serializers.ListField): """ def __init__(self, *args, **kwargs): - kwargs.setdefault('child', serializers.CharField(allow_blank=False, max_length=1024)) + kwargs.setdefault('child', serializers.CharField( + allow_blank=False, max_length=MAX_FILENAME_LENGTH + )) kwargs.setdefault('allow_empty', False) super().__init__(*args, **kwargs) @@ -895,7 +925,8 @@ class ValidationParamsSerializer(serializers.Serializer): mode = serializers.ChoiceField(choices=models.ValidationMode.choices(), required=True) frame_selection_method = serializers.ChoiceField(choices=models.JobFrameSelectionMethod.choices(), required=True) frames = serializers.ListSerializer( - child=serializers.CharField(max_length=1024), default=[], required=False, allow_null=True + child=serializers.CharField(max_length=MAX_FILENAME_LENGTH), + default=[], required=False, allow_null=True ) frames_count = serializers.IntegerField(required=False, allow_null=True) frames_percent = serializers.FloatField(required=False, allow_null=True) @@ -985,7 +1016,7 @@ class DataSerializer(serializers.ModelSerializer): Must contain all files from job_file_mapping if job_file_mapping is not empty. """)) server_files_exclude = serializers.ListField(required=False, default=[], - child=serializers.CharField(max_length=1024), + child=serializers.CharField(max_length=MAX_FILENAME_LENGTH), help_text=textwrap.dedent("""\ Paths to files and directories from a file share mounted on the server, or from a cloud storage that should be excluded from the directories specified in server_files. @@ -1033,7 +1064,7 @@ class DataSerializer(serializers.ModelSerializer): job_file_mapping = JobFileMapping(required=False, write_only=True) upload_file_order = serializers.ListField( - child=serializers.CharField(max_length=1024), + child=serializers.CharField(max_length=MAX_FILENAME_LENGTH), default=list, allow_empty=True, write_only=True, help_text=textwrap.dedent("""\ Allows to specify file order for client_file uploads. @@ -1523,7 +1554,7 @@ class AboutSerializer(serializers.Serializer): class FrameMetaSerializer(serializers.Serializer): width = serializers.IntegerField() height = serializers.IntegerField() - name = serializers.CharField(max_length=1024) + name = serializers.CharField(max_length=MAX_FILENAME_LENGTH) related_files = serializers.IntegerField() # for compatibility with version 2.3.0 @@ -1716,7 +1747,7 @@ class LabeledDataSerializer(serializers.Serializer): tracks = LabeledTrackSerializer(many=True, default=[]) class FileInfoSerializer(serializers.Serializer): - name = serializers.CharField(max_length=1024) + name = serializers.CharField(max_length=MAX_FILENAME_LENGTH) type = serializers.ChoiceField(choices=["REG", "DIR"]) mime_type = serializers.CharField(max_length=255) @@ -2207,7 +2238,7 @@ def _configure_related_storages(validated_data: Dict[str, Any]) -> Dict[str, Opt return storages class AssetReadSerializer(WriteOnceMixin, serializers.ModelSerializer): - filename = serializers.CharField(required=True, max_length=1024) + filename = serializers.CharField(required=True, max_length=MAX_FILENAME_LENGTH) owner = BasicUserSerializer(required=False) class Meta: @@ -2217,7 +2248,7 @@ class Meta: class AssetWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): uuid = serializers.CharField(required=False) - filename = serializers.CharField(required=True, max_length=1024) + filename = serializers.CharField(required=True, max_length=MAX_FILENAME_LENGTH) guide_id = serializers.IntegerField(required=True) class Meta: diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 194ec6761b8e..02905fb023ad 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -27,8 +27,10 @@ from cvat.apps.engine import models from cvat.apps.engine.log import ServerLogManager -from cvat.apps.engine.media_extractors import (MEDIA_TYPES, ImageListReader, Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter, - ValidateDimension, ZipChunkWriter, ZipCompressedChunkWriter, get_mime, sort) +from cvat.apps.engine.media_extractors import ( + MEDIA_TYPES, IMediaReader, ImageListReader, Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter, + ValidateDimension, ZipChunkWriter, ZipCompressedChunkWriter, get_mime, sort +) from cvat.apps.engine.utils import ( av_scan_paths,get_rq_job_meta, define_dependent_job, get_rq_lock_by_user, preload_images ) @@ -798,7 +800,7 @@ def _update_status(msg: str) -> None: ) # Extract input data - extractor = None + extractor: Optional[IMediaReader] = None manifest_index = _get_manifest_frame_indexer() for media_type, media_files in media.items(): if not media_files: @@ -958,19 +960,6 @@ def _update_status(msg: str) -> None: db_data.compressed_chunk_type = models.DataChoice.VIDEO if task_mode == 'interpolation' and not data['use_zip_chunks'] else models.DataChoice.IMAGESET db_data.original_chunk_type = models.DataChoice.VIDEO if task_mode == 'interpolation' else models.DataChoice.IMAGESET - def update_progress(progress): - progress_animation = '|/-\\' - if not hasattr(update_progress, 'call_counter'): - update_progress.call_counter = 0 - - status_message = 'CVAT is preparing data chunks' - if not progress: - status_message = '{} {}'.format(status_message, progress_animation[update_progress.call_counter]) - job.meta['status'] = status_message - job.meta['task_progress'] = progress or 0. - job.save_meta() - update_progress.call_counter = (update_progress.call_counter + 1) % len(progress_animation) - compressed_chunk_writer_class = Mpeg4CompressedChunkWriter if db_data.compressed_chunk_type == models.DataChoice.VIDEO else ZipCompressedChunkWriter if db_data.original_chunk_type == models.DataChoice.VIDEO: original_chunk_writer_class = Mpeg4ChunkWriter @@ -1001,183 +990,156 @@ def update_progress(progress): else: db_data.chunk_size = 36 - video_path = "" - video_size = (0, 0) + # TODO: try to pull up + # replace manifest file (e.g was uploaded 'subdir/manifest.jsonl' or 'some_manifest.jsonl') + if ( + settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE and + manifest_file and not os.path.exists(db_data.get_manifest_path()) + ): + shutil.copyfile(os.path.join(manifest_root, manifest_file), + db_data.get_manifest_path()) + if manifest_root and manifest_root.startswith(db_data.get_upload_dirname()): + os.remove(os.path.join(manifest_root, manifest_file)) + manifest_file = os.path.relpath(db_data.get_manifest_path(), upload_dir) - db_images: list[models.Image] = [] + video_path: str = "" + video_size: tuple[int, int] = (0, 0) - if settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE: - for media_type, media_files in media.items(): - if not media_files: - continue + images: list[models.Image] = [] - # replace manifest file (e.g was uploaded 'subdir/manifest.jsonl' or 'some_manifest.jsonl') - if manifest_file and not os.path.exists(db_data.get_manifest_path()): - shutil.copyfile(os.path.join(manifest_root, manifest_file), - db_data.get_manifest_path()) - if manifest_root and manifest_root.startswith(db_data.get_upload_dirname()): - os.remove(os.path.join(manifest_root, manifest_file)) - manifest_file = os.path.relpath(db_data.get_manifest_path(), upload_dir) + # Collect media metadata + for media_type, media_files in media.items(): + if not media_files: + continue - if task_mode == MEDIA_TYPES['video']['mode']: + if task_mode == MEDIA_TYPES['video']['mode']: + manifest_is_prepared = False + if manifest_file: try: - manifest_is_prepared = False - if manifest_file: - try: - manifest = VideoManifestValidator(source_path=os.path.join(upload_dir, media_files[0]), - manifest_path=db_data.get_manifest_path()) - manifest.init_index() - manifest.validate_seek_key_frames() - assert len(manifest) > 0, 'No key frames.' - - all_frames = manifest.video_length - video_size = manifest.video_resolution - manifest_is_prepared = True - except Exception as ex: - manifest.remove() - if isinstance(ex, AssertionError): - base_msg = str(ex) - else: - base_msg = 'Invalid manifest file was upload.' - slogger.glob.warning(str(ex)) - _update_status('{} Start prepare a valid manifest file.'.format(base_msg)) - - if not manifest_is_prepared: - _update_status('Start prepare a manifest file') - manifest = VideoManifestManager(db_data.get_manifest_path()) - manifest.link( - media_file=media_files[0], - upload_dir=upload_dir, - chunk_size=db_data.chunk_size - ) - manifest.create() - _update_status('A manifest had been created') + _update_status('Validating the input manifest file') - all_frames = len(manifest.reader) - video_size = manifest.reader.resolution - manifest_is_prepared = True + manifest = VideoManifestValidator( + source_path=os.path.join(upload_dir, media_files[0]), + manifest_path=db_data.get_manifest_path() + ) + manifest.init_index() + manifest.validate_seek_key_frames() - db_data.size = len(range(db_data.start_frame, min(data['stop_frame'] + 1 \ - if data['stop_frame'] else all_frames, all_frames), db_data.get_frame_step())) - video_path = os.path.join(upload_dir, media_files[0]) + if not len(manifest): + raise ValidationError("No key frames found in the manifest") + + all_frames = manifest.video_length + video_size = manifest.video_resolution + manifest_is_prepared = True except Exception as ex: - db_data.storage_method = models.StorageMethodChoice.FILE_SYSTEM manifest.remove() - del manifest + manifest = None + + slogger.glob.warning(ex, exc_info=True) + if isinstance(ex, (ValidationError, AssertionError)): + _update_status(f'Invalid manifest file was upload: {ex}') + + if ( + settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE + and not manifest_is_prepared + ): + # TODO: check if we can always use video manifest for optimization + try: + _update_status('Preparing a manifest file') + + # TODO: maybe generate manifest in a temp directory + manifest = VideoManifestManager(db_data.get_manifest_path()) + manifest.link( + media_file=media_files[0], + upload_dir=upload_dir, + chunk_size=db_data.chunk_size + ) + manifest.create() + + _update_status('A manifest has been created') + + all_frames = len(manifest.reader) # TODO: check if the field access above and here are equivalent + video_size = manifest.reader.resolution + manifest_is_prepared = True + except Exception as ex: + manifest.remove() + manifest = None + + db_data.storage_method = models.StorageMethodChoice.FILE_SYSTEM + base_msg = str(ex) if isinstance(ex, AssertionError) \ else "Uploaded video does not support a quick way of task creating." _update_status("{} The task will be created using the old method".format(base_msg)) - else: # images, archive, pdf - db_data.size = len(extractor) - manifest = ImageManifestManager(db_data.get_manifest_path()) + if not manifest: + all_frames = len(extractor) + video_size = extractor.get_image_size(0) + + db_data.size = len(range( + db_data.start_frame, + min( + data['stop_frame'] + 1 if data['stop_frame'] else all_frames, + all_frames, + ), + db_data.get_frame_step() + )) + video_path = os.path.join(upload_dir, media_files[0]) + else: # images, archive, pdf + db_data.size = len(extractor) + + manifest = None + if settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE: + manifest = ImageManifestManager(db_data.get_manifest_path()) if not manifest.exists: manifest.link( sources=extractor.absolute_source_paths, - meta={ k: {'related_images': related_images[k] } for k in related_images }, + meta={ + k: {'related_images': related_images[k] } + for k in related_images + }, data_dir=upload_dir, DIM_3D=(db_task.dimension == models.DimensionType.DIM_3D), ) manifest.create() else: manifest.init_index() - counter = itertools.count() - for _, chunk_frames in itertools.groupby(extractor.frame_range, lambda x: next(counter) // db_data.chunk_size): - chunk_paths = [(extractor.get_path(i), i) for i in chunk_frames] - img_sizes = [] - - for chunk_path, frame_id in chunk_paths: - properties = manifest[manifest_index(frame_id)] - - # check mapping - if not chunk_path.endswith(f"{properties['name']}{properties['extension']}"): - raise Exception('Incorrect file mapping to manifest content') - - if db_task.dimension == models.DimensionType.DIM_2D and ( - properties.get('width') is not None and - properties.get('height') is not None - ): - resolution = (properties['width'], properties['height']) - elif is_data_in_cloud: - raise Exception( - "Can't find image '{}' width or height info in the manifest" - .format(f"{properties['name']}{properties['extension']}") - ) - else: - resolution = extractor.get_image_size(frame_id) - img_sizes.append(resolution) - - db_images.extend([ - models.Image(data=db_data, - path=os.path.relpath(path, upload_dir), - frame=frame, width=w, height=h) - for (path, frame), (w, h) in zip(chunk_paths, img_sizes) - ]) - if db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM or not settings.USE_CACHE: - counter = itertools.count() - generator = itertools.groupby(extractor, lambda _: next(counter) // db_data.chunk_size) - generator = ((idx, list(chunk_data)) for idx, chunk_data in generator) - - def save_chunks( - executor: concurrent.futures.ThreadPoolExecutor, - chunk_idx: int, - chunk_data: Iterable[tuple[str, str, str]]) -> list[tuple[str, int, tuple[int, int]]]: - nonlocal db_data, db_task, extractor, original_chunk_writer, compressed_chunk_writer - if (db_task.dimension == models.DimensionType.DIM_2D and - isinstance(extractor, ( - MEDIA_TYPES['image']['extractor'], - MEDIA_TYPES['zip']['extractor'], - MEDIA_TYPES['pdf']['extractor'], - MEDIA_TYPES['archive']['extractor'], - ))): - chunk_data = preload_images(chunk_data) - - fs_original = executor.submit( - original_chunk_writer.save_as_chunk, - images=chunk_data, - chunk_path=db_data.get_original_chunk_path(chunk_idx) - ) - fs_compressed = executor.submit( - compressed_chunk_writer.save_as_chunk, - images=chunk_data, - chunk_path=db_data.get_compressed_chunk_path(chunk_idx), - ) - fs_original.result() - image_sizes = fs_compressed.result() - # (path, frame, size) - return list((i[0][1], i[0][2], i[1]) for i in zip(chunk_data, image_sizes)) + for frame_id in extractor.frame_range: + image_path = extractor.get_path(frame_id) + image_size = None + + if manifest: + image_info = manifest[manifest_index(frame_id)] + + # check mapping + if not image_path.endswith(f"{image_info['name']}{image_info['extension']}"): + raise ValidationError('Incorrect file mapping to manifest content') + + if db_task.dimension == models.DimensionType.DIM_2D and ( + image_info.get('width') is not None and + image_info.get('height') is not None + ): + image_size = (image_info['width'], image_info['height']) + elif is_data_in_cloud: + raise ValidationError( + "Can't find image '{}' width or height info in the manifest" + .format(f"{image_info['name']}{image_info['extension']}") + ) - def process_results(img_meta: list[tuple[str, int, tuple[int, int]]]): - nonlocal db_images, db_data, video_path, video_size + if not image_size: + image_size = extractor.get_image_size(frame_id) - if db_task.mode == 'annotation': - db_images.extend( + images.append( models.Image( data=db_data, - path=os.path.relpath(frame_path, upload_dir), - frame=frame_number, - width=frame_size[0], - height=frame_size[1]) - for frame_path, frame_number, frame_size in img_meta) - else: - video_size = img_meta[0][2] - video_path = img_meta[0][0] - - progress = extractor.get_progress(img_meta[-1][1]) - update_progress(progress) - - futures = queue.Queue(maxsize=settings.CVAT_CONCURRENT_CHUNK_PROCESSING) - with concurrent.futures.ThreadPoolExecutor(max_workers=2*settings.CVAT_CONCURRENT_CHUNK_PROCESSING) as executor: - for chunk_idx, chunk_data in generator: - db_data.size += len(chunk_data) - if futures.full(): - process_results(futures.get().result()) - futures.put(executor.submit(save_chunks, executor, chunk_idx, chunk_data)) - - while not futures.empty(): - process_results(futures.get().result()) + path=os.path.relpath(image_path, upload_dir), + frame=frame_id, + width=image_size[0], + height=image_size[1], + ) + ) + # Prepare jobs if validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: if db_task.mode != 'annotation': raise ValidationError("gt pool can only be used with 'annotation' mode tasks") @@ -1193,7 +1155,7 @@ def process_results(img_meta: list[tuple[str, int, tuple[int, int]]]): from numpy import random rng = random.Generator(random.MT19937(seed=seed)) - all_frames = range(len(db_images)) + all_frames = range(len(images)) pool_frames: list[int] = rng.choice( all_frames, size=frames_count, shuffle=False, replace=False ).tolist() @@ -1202,9 +1164,10 @@ def process_results(img_meta: list[tuple[str, int, tuple[int, int]]]): # 2. distribute pool frames from datumaro.util import take_by - job_file_mapping = [] - new_db_images = [] - frame_id_map = {} + # Allocate frames for jobs + job_file_mapping: JobFileMapping = [] + new_db_images: list[models.Image] = [] + validation_frames: list[int] = [] for job_frames in take_by(non_pool_frames, count=frames_per_job_count): job_validation_frames = rng.choice(pool_frames, size=frames_per_job_count, replace=False) job_frames += job_validation_frames.tolist() @@ -1214,45 +1177,75 @@ def process_results(img_meta: list[tuple[str, int, tuple[int, int]]]): job_images = [] for job_frame in job_frames: # Insert placeholder frames into the frame sequence and shift frame ids - image = models.Image(data=db_data, **deepcopy(model_to_dict(db_images[job_frame], exclude=["data"]))) - image.frame = frame_id_map.setdefault(job_frame, len(new_db_images)) + image = images[job_frame] + image = models.Image( + data=db_data, **deepcopy(model_to_dict(image, exclude=["data"])) + ) + image.frame = len(new_db_images) if job_frame in job_validation_frames: image.is_placeholder = True + image.real_frame_id = job_frame + validation_frames.append(image.frame) - job_images.append(image) + job_images.append(image.path) new_db_images.append(image) job_file_mapping.append(job_images) + # Append pool frames in the end, shift their ids, establish placeholder pointers + frame_id_map: dict[int, int] = {} # original to new id + for pool_frame in pool_frames: + # Insert placeholder frames into the frame sequence and shift frame ids + image = images[pool_frame] + image = models.Image( + data=db_data, **deepcopy(model_to_dict(image, exclude=["data"])) + ) + new_frame_id = len(new_db_images) + image.frame = new_frame_id + + frame_id_map[pool_frame] = new_frame_id + + new_db_images.append(image) + pool_frames = [frame_id_map[i] for i in pool_frames if i in frame_id_map] + # Store information about the real frame placement in the validation frames + for validation_frame in validation_frames: + image = new_db_images[validation_frame] + assert image.is_placeholder + image.real_frame_id = frame_id_map[image.real_frame_id] + + db_data.size = len(new_db_images) + images = new_db_images + if db_task.mode == 'annotation': - models.Image.objects.bulk_create(db_images) + models.Image.objects.bulk_create(images) created_images = models.Image.objects.filter(data_id=db_data.id) db_related_files = [ models.RelatedFile(data=image.data, primary_image=image, path=os.path.join(upload_dir, related_file_path)) for image in created_images for related_file_path in related_images.get(image.path, []) + if not image.is_placeholder # TODO ] models.RelatedFile.objects.bulk_create(db_related_files) - db_images = [] else: models.Video.objects.create( data=db_data, path=os.path.relpath(video_path, upload_dir), - width=video_size[0], height=video_size[1]) + width=video_size[0], height=video_size[1] + ) + # validate stop_frame if db_data.stop_frame == 0: db_data.stop_frame = db_data.start_frame + (db_data.size - 1) * db_data.get_frame_step() else: - # validate stop_frame db_data.stop_frame = min(db_data.stop_frame, \ db_data.start_frame + (db_data.size - 1) * db_data.get_frame_step()) slogger.glob.info("Found frames {} for Data #{}".format(db_data.size, db_data.id)) - _save_task_to_db(db_task, job_file_mapping=job_file_mapping) + _save_task_to_db(db_task, job_file_mapping=job_file_mapping) # TODO: split into jobs and task saving if validation_params: db_gt_segment = models.Segment( @@ -1267,3 +1260,71 @@ def process_results(img_meta: list[tuple[str, int, tuple[int, int]]]): db_gt_job = models.Job(segment=db_gt_segment, type=models.JobType.GROUND_TRUTH) db_gt_job.save() db_gt_job.make_dirs() + + # Save chunks + # TODO: refactor + # TODO: save chunks per job + if db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM or not settings.USE_CACHE: + def update_progress(progress): + progress_animation = '|/-\\' + if not hasattr(update_progress, 'call_counter'): + update_progress.call_counter = 0 + + status_message = 'CVAT is preparing data chunks' + if not progress: + status_message = '{} {}'.format(status_message, progress_animation[update_progress.call_counter]) + job.meta['status'] = status_message + job.meta['task_progress'] = progress or 0. + job.save_meta() + update_progress.call_counter = (update_progress.call_counter + 1) % len(progress_animation) + + counter = itertools.count() + generator = itertools.groupby(extractor, lambda _: next(counter) // db_data.chunk_size) + generator = ((idx, list(chunk_data)) for idx, chunk_data in generator) + + def save_chunks( + executor: concurrent.futures.ThreadPoolExecutor, + chunk_idx: int, + chunk_data: Iterable[tuple[str, str, str]] + ) -> list[tuple[str, int, tuple[int, int]]]: + if (db_task.dimension == models.DimensionType.DIM_2D and + isinstance(extractor, ( + MEDIA_TYPES['image']['extractor'], + MEDIA_TYPES['zip']['extractor'], + MEDIA_TYPES['pdf']['extractor'], + MEDIA_TYPES['archive']['extractor'], + ))): + chunk_data = preload_images(chunk_data) + + fs_original = executor.submit( + original_chunk_writer.save_as_chunk, + images=chunk_data, + chunk_path=db_data.get_original_chunk_path(chunk_idx) + ) + fs_compressed = executor.submit( + compressed_chunk_writer.save_as_chunk, + images=chunk_data, + chunk_path=db_data.get_compressed_chunk_path(chunk_idx), + ) + fs_original.result() + image_sizes = fs_compressed.result() + + # (path, frame, size) + return list((i[0][1], i[0][2], i[1]) for i in zip(chunk_data, image_sizes)) + + def process_results(img_meta: list[tuple[str, int, tuple[int, int]]]): + progress = extractor.get_progress(img_meta[-1][1]) + update_progress(progress) + + queue = queue.Queue(maxsize=settings.CVAT_CONCURRENT_CHUNK_PROCESSING) + with concurrent.futures.ThreadPoolExecutor( + max_workers=2 * settings.CVAT_CONCURRENT_CHUNK_PROCESSING + ) as executor: + for chunk_idx, chunk_data in generator: + db_data.size += len(chunk_data) + if queue.full(): + process_results(queue.get().result()) + queue.put(executor.submit(save_chunks, executor, chunk_idx, chunk_data)) + + while not queue.empty(): + process_results(queue.get().result()) diff --git a/cvat/apps/engine/utils.py b/cvat/apps/engine/utils.py index 0219bcb04684..ae0444a1eba5 100644 --- a/cvat/apps/engine/utils.py +++ b/cvat/apps/engine/utils.py @@ -11,7 +11,7 @@ import sys import traceback from contextlib import suppress, nullcontext -from typing import Any, Dict, Optional, Callable, Union, Iterable +from typing import Any, Dict, Optional, Callable, Sequence, Union, Iterable import subprocess import os import urllib.parse @@ -412,3 +412,21 @@ def directory_tree(path, max_depth=None) -> str: def is_dataset_export(request: HttpRequest) -> bool: return to_bool(request.query_params.get('save_images', False)) + + +LIST_DISPLAY_THRESHOLD = 10 +""" +Controls maximum rendered list items. The remainder is appended as '(and X more)'. +""" + +def format_list( + items: Sequence[str], *, max_items: int = None, separator: str = ", " +) -> str: + if max_items is None: + max_items = LIST_DISPLAY_THRESHOLD + + remainder_count = len(items) - max_items + return "{}{}".format( + separator.join(items[:max_items]), + f" (and {remainder_count} more)" if remainder_count > 0 else "", + ) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 20862e8673f1..caf56cc5c4be 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -6,6 +6,7 @@ import os import os.path as osp import functools +import random from PIL import Image from types import SimpleNamespace from typing import Optional, Any, Dict, List, cast, Callable, Mapping, Iterable @@ -65,7 +66,7 @@ from cvat.apps.engine.serializers import ( AboutSerializer, AnnotationFileSerializer, BasicUserSerializer, DataMetaReadSerializer, DataMetaWriteSerializer, DataSerializer, - FileInfoSerializer, JobReadSerializer, JobWriteSerializer, LabelSerializer, + FileInfoSerializer, JobHoneypotReadSerializer, JobHoneypotWriteSerializer, JobReadSerializer, JobWriteSerializer, LabelSerializer, LabeledDataSerializer, ProjectReadSerializer, ProjectWriteSerializer, RqStatusSerializer, TaskReadSerializer, TaskWriteSerializer, @@ -81,7 +82,7 @@ from utils.dataset_manifest import ImageManifestManager from cvat.apps.engine.utils import ( - av_scan_paths, process_failed_job, + av_scan_paths, format_list, process_failed_job, parse_exception_message, get_rq_job_meta, import_resource_with_clean_up_after, sendfile, define_dependent_job, get_rq_lock_by_user, ) @@ -746,8 +747,7 @@ def __init__(self, job: Job, data_type, data_num, data_quality): self.job = job def _check_frame_range(self, frame: int): - frame_range = self.job.segment.frame_set - if frame not in frame_range: + if frame not in self.job.segment.frame_set: raise ValidationError("The frame number doesn't belong to the job") def __call__(self, request, start, stop, db_data): @@ -2139,6 +2139,148 @@ def preview(self, request, pk): return data_getter(request, self._object.segment.start_frame, self._object.segment.stop_frame, self._object.segment.task.data) + @extend_schema( + methods=["GET"], + summary="Allows to get current honeypot frames", + responses={ + '200': OpenApiResponse(JobHoneypotReadSerializer), + }) + @extend_schema( + methods=["PATCH"], + summary="Allows to update current honeypot frames", + request=JobHoneypotWriteSerializer, + responses={ + '200': OpenApiResponse(JobHoneypotReadSerializer), + }) + @action(detail=True, methods=["GET", "PATCH"], url_path='honeypot') + def honeypot(self, request, pk): + db_job: models.Job = self.get_object() # call check_object_permissions as well + + if ( + not db_job.segment.task.data.validation_params or + db_job.segment.task.data.validation_params.mode != models.ValidationMode.GT_POOL + ): + raise ValidationError("Honeypots are not configured in the task") + + db_segment = db_job.segment + task_all_honeypots = set(db_segment.task.gt_job.segment.frame_set) + segment_honeypots = set(db_segment.frame_set) & task_all_honeypots + + if request.method == "PATCH": + request_serializer = JobHoneypotWriteSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + input_data = request_serializer.validated_data + + deleted_task_frames = db_segment.task.data.deleted_frames + task_active_honeypots = task_all_honeypots.difference(deleted_task_frames) + + segment_honeypots_count = len(segment_honeypots) + + db_task_frames: dict[int, models.Image] = { + frame.frame: frame for frame in db_segment.task.data.images.all() + } + + frame_selection_method = input_data['frame_selection_method'] + if frame_selection_method == models.JobFrameSelectionMethod.MANUAL: + task_honeypot_frame_map: dict[str, int] = { + v.path: k for k, v in db_task_frames.items() + } + + requested_frame_names: list[str] = input_data['frames'] + requested_frame_ids: list[int] = [] + requested_unknown_frames: list[str] = [] + requested_inactive_frames: list[str] = [] + for requested_frame_name in requested_frame_names: + requested_frame_id = task_honeypot_frame_map.get(requested_frame_name) + + if requested_frame_id is None: + requested_unknown_frames.append(requested_frame_name) + continue + + if requested_frame_id not in task_active_honeypots: + requested_inactive_frames.append(requested_frame_name) + continue + + requested_frame_ids.append(task_honeypot_frame_map) + + if len(set(requested_frame_ids)) != len(requested_frame_names): + raise ValidationError( + "Could not update honeypot frames: validation frames cannot repeat" + ) + + if requested_unknown_frames: + raise ValidationError( + "Could not update honeypot frames: " + "frames {} do not exist in the task".format( + format_list(requested_unknown_frames) + ) + ) + + if requested_inactive_frames: + raise ValidationError( + "Could not update honeypot frames: frames {} are removed. " + "Restore them in the honeypot pool first.".format( + format_list(requested_inactive_frames) + ) + ) + + if len(requested_frame_names) != segment_honeypots_count: + raise ValidationError( + "Could not update honeypot frames: " + "the requested number of validation frames must be remain the same." + "Requested {}, current {}".format( + len(requested_frame_names), segment_honeypots_count + ) + ) + + elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: + # TODO: take current validation frames distribution into account + # simply using random here will break uniformity + requested_frame_ids = random.sample( + task_active_honeypots, k=segment_honeypots_count + ) + + # Replace validation frames in the job + updated_db_frames = [] + new_validation_frame_iter = iter(requested_frame_ids) + for current_frame_id in db_segment.frame_set: + if current_frame_id in segment_honeypots: + requested_frame_id = next(new_validation_frame_iter) + db_requested_frame = db_task_frames[requested_frame_id] + db_segment_frame = db_task_frames[current_frame_id] + assert db_segment_frame.is_placeholder + + # Change image in the current segment frame + db_segment_frame.path = db_requested_frame.path + db_segment_frame.width = db_requested_frame.width + db_segment_frame.height = db_requested_frame.height + + updated_db_frames.append(db_segment_frame) + + assert next(new_validation_frame_iter, None) is None + + models.Image.objects.bulk_update(updated_db_frames, fields=['path', 'width', 'height']) + db_segment.save() + + frame_provider = FrameProvider(db_job.segment.task.data) + updated_segment_chunk_ids = set( + frame_provider.get_chunk_number(updated_segment_frame_id) + for updated_segment_frame_id in requested_frame_ids + ) + + # TODO: maybe there is better way to regenerate chunks + # (e.g. replace specific files directly) + media_cache = MediaCache() + for chunk_id in updated_segment_chunk_ids: + for quality in FrameProvider.Quality.__members__.values(): + media_cache.remove_task_chunk( + chunk_id, quality=quality, db_data=db_segment.task.data + ) + + segment_honeypots = set(requested_frame_ids) + + response_serializer = JobHoneypotReadSerializer({'frames': sorted(segment_honeypots)}) + return Response(response_serializer.data, status=status.HTTP_200_OK) @extend_schema(tags=['issues']) @extend_schema_view( From 9a095109bf3f987e378a8ab2996b6e0014523882 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 11 Jul 2024 18:06:10 +0300 Subject: [PATCH 004/227] Update chunk and manifest creation --- cvat/apps/engine/cache.py | 3 +- cvat/apps/engine/task.py | 64 ++++++++++++++++++++++++++++++--------- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 60b5d4f03a7f..bf4d331c4462 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -167,6 +167,7 @@ def _get_images(db_data, chunk_number, dimension): if hasattr(db_data, 'video'): source_path = os.path.join(upload_dir, db_data.video.path) + # TODO: refactor to allow non-manifest videos reader = VideoDatasetManifestReader(manifest_path=db_data.get_manifest_path(), source_path=source_path, chunk_number=chunk_number, chunk_size=db_data.chunk_size, start=db_data.start_frame, @@ -297,7 +298,7 @@ def prepare_selective_job_chunk(self, db_job: Job, quality, chunk_number: int): buff = BytesIO() writer.save_as_chunk(chunk_frames, buff, compress_frames=False, - zip_compress_level=1 # these are likely to be many skips in SPECIFIC_FRAMES segments + zip_compress_level=1 # there are likely to be many skips in SPECIFIC_FRAMES segments ) buff.seek(0) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 02905fb023ad..88d827ec868a 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1140,6 +1140,7 @@ def _update_status(msg: str) -> None: ) # Prepare jobs + frame_idx_map = None if validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: if db_task.mode != 'annotation': raise ValidationError("gt pool can only be used with 'annotation' mode tasks") @@ -1168,6 +1169,7 @@ def _update_status(msg: str) -> None: job_file_mapping: JobFileMapping = [] new_db_images: list[models.Image] = [] validation_frames: list[int] = [] + frame_idx_map: dict[int, int] = {} # new to original id for job_frames in take_by(non_pool_frames, count=frames_per_job_count): job_validation_frames = rng.choice(pool_frames, size=frames_per_job_count, replace=False) job_frames += job_validation_frames.tolist() @@ -1190,6 +1192,7 @@ def _update_status(msg: str) -> None: job_images.append(image.path) new_db_images.append(image) + frame_idx_map[image.frame] = job_frame job_file_mapping.append(job_images) @@ -1207,6 +1210,7 @@ def _update_status(msg: str) -> None: frame_id_map[pool_frame] = new_frame_id new_db_images.append(image) + frame_idx_map[image.frame] = pool_frame pool_frames = [frame_id_map[i] for i in pool_frames if i in frame_id_map] @@ -1221,11 +1225,11 @@ def _update_status(msg: str) -> None: if db_task.mode == 'annotation': models.Image.objects.bulk_create(images) - created_images = models.Image.objects.filter(data_id=db_data.id) + images = models.Image.objects.filter(data_id=db_data.id) db_related_files = [ models.RelatedFile(data=image.data, primary_image=image, path=os.path.join(upload_dir, related_file_path)) - for image in created_images + for image in images for related_file_path in related_images.get(image.path, []) if not image.is_placeholder # TODO ] @@ -1261,11 +1265,28 @@ def _update_status(msg: str) -> None: db_gt_job.save() db_gt_job.make_dirs() + # Update manifest + if ( + settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE + ) and task_mode == "annotation" and frame_idx_map: + manifest = ImageManifestManager(db_data.get_manifest_path()) + manifest.link( + sources=[extractor.get_path(frame_idx_map[image.frame]) for image in images], + meta={ + k: {'related_images': related_images[k] } + for k in related_images + }, + data_dir=upload_dir, + DIM_3D=(db_task.dimension == models.DimensionType.DIM_3D), + ) + manifest.create() + # Save chunks # TODO: refactor # TODO: save chunks per job if db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM or not settings.USE_CACHE: def update_progress(progress): + # TODO: refactor this function into a class progress_animation = '|/-\\' if not hasattr(update_progress, 'call_counter'): update_progress.call_counter = 0 @@ -1278,22 +1299,37 @@ def update_progress(progress): job.save_meta() update_progress.call_counter = (update_progress.call_counter + 1) % len(progress_animation) + + if db_task.mode == "annotation" and frame_idx_map: + generator = ( + ( + extractor.get_image(frame_idx_map[image.frame]), + extractor.get_path(frame_idx_map[image.frame]), + image.frame, + ) + for image in images + ) + else: + generator = extractor + counter = itertools.count() - generator = itertools.groupby(extractor, lambda _: next(counter) // db_data.chunk_size) - generator = ((idx, list(chunk_data)) for idx, chunk_data in generator) + generator = itertools.groupby(generator, lambda _: next(counter) // db_data.chunk_size) + generator = ((chunk_idx, list(chunk_data)) for chunk_idx, chunk_data in generator) def save_chunks( executor: concurrent.futures.ThreadPoolExecutor, chunk_idx: int, chunk_data: Iterable[tuple[str, str, str]] ) -> list[tuple[str, int, tuple[int, int]]]: - if (db_task.dimension == models.DimensionType.DIM_2D and + if ( + db_task.dimension == models.DimensionType.DIM_2D and isinstance(extractor, ( MEDIA_TYPES['image']['extractor'], MEDIA_TYPES['zip']['extractor'], MEDIA_TYPES['pdf']['extractor'], MEDIA_TYPES['archive']['extractor'], - ))): + )) + ): chunk_data = preload_images(chunk_data) fs_original = executor.submit( @@ -1306,6 +1342,7 @@ def save_chunks( images=chunk_data, chunk_path=db_data.get_compressed_chunk_path(chunk_idx), ) + # TODO: convert to async for proper concurrency fs_original.result() image_sizes = fs_compressed.result() @@ -1313,18 +1350,17 @@ def save_chunks( return list((i[0][1], i[0][2], i[1]) for i in zip(chunk_data, image_sizes)) def process_results(img_meta: list[tuple[str, int, tuple[int, int]]]): - progress = extractor.get_progress(img_meta[-1][1]) + progress = img_meta[-1][1] / db_data.size update_progress(progress) - queue = queue.Queue(maxsize=settings.CVAT_CONCURRENT_CHUNK_PROCESSING) + futures = queue.Queue(maxsize=settings.CVAT_CONCURRENT_CHUNK_PROCESSING) with concurrent.futures.ThreadPoolExecutor( max_workers=2 * settings.CVAT_CONCURRENT_CHUNK_PROCESSING ) as executor: for chunk_idx, chunk_data in generator: - db_data.size += len(chunk_data) - if queue.full(): - process_results(queue.get().result()) - queue.put(executor.submit(save_chunks, executor, chunk_idx, chunk_data)) + if futures.full(): + process_results(futures.get().result()) + futures.put(executor.submit(save_chunks, executor, chunk_idx, chunk_data)) - while not queue.empty(): - process_results(queue.get().result()) + while not futures.empty(): + process_results(futures.get().result()) From 011d5ca1e552873f8af09f63fb902ee94334a6b9 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 11 Jul 2024 18:16:43 +0300 Subject: [PATCH 005/227] Fix segment size application --- cvat/apps/engine/task.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 88d827ec868a..51ff9aa647a1 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1170,7 +1170,7 @@ def _update_status(msg: str) -> None: new_db_images: list[models.Image] = [] validation_frames: list[int] = [] frame_idx_map: dict[int, int] = {} # new to original id - for job_frames in take_by(non_pool_frames, count=frames_per_job_count): + for job_frames in take_by(non_pool_frames, count=db_task.segment_size or db_data.size): job_validation_frames = rng.choice(pool_frames, size=frames_per_job_count, replace=False) job_frames += job_validation_frames.tolist() @@ -1218,7 +1218,7 @@ def _update_status(msg: str) -> None: for validation_frame in validation_frames: image = new_db_images[validation_frame] assert image.is_placeholder - image.real_frame_id = frame_id_map[image.real_frame_id] + image.real_frame_id = frame_id_map[image.real_frame_id] # TODO: maybe not needed db_data.size = len(new_db_images) images = new_db_images From 19e4fb22be6a6e3a7674847c36a585fb3182d6f1 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 15 Jul 2024 16:48:03 +0300 Subject: [PATCH 006/227] Fix honeypot updating in a job --- cvat/apps/engine/views.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index caf56cc5c4be..7979b9f5e8e5 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -2154,7 +2154,15 @@ def preview(self, request, pk): }) @action(detail=True, methods=["GET", "PATCH"], url_path='honeypot') def honeypot(self, request, pk): - db_job: models.Job = self.get_object() # call check_object_permissions as well + self.get_object() # call check_object_permissions as well + + db_job = models.Job.objects.prefetch_related( + 'segment', + 'segment__task', + Prefetch('segment__task__data', queryset=models.Data.objects.select_related('video').prefetch_related( + Prefetch('images', queryset=models.Image.objects.prefetch_related('related_files').order_by('frame')) + )) + ).get(pk=pk) if ( not db_job.segment.task.data.validation_params or @@ -2164,7 +2172,15 @@ def honeypot(self, request, pk): db_segment = db_job.segment task_all_honeypots = set(db_segment.task.gt_job.segment.frame_set) - segment_honeypots = set(db_segment.frame_set) & task_all_honeypots + + db_task_frames: dict[int, models.Image] = { + frame.frame: frame for frame in db_segment.task.data.images.all() + } + task_placeholder_frames = set( + frame_id for frame_id, frame in db_task_frames.items() + if frame.is_placeholder + ) + segment_honeypots = set(db_segment.frame_set) & task_placeholder_frames if request.method == "PATCH": request_serializer = JobHoneypotWriteSerializer(data=request.data) @@ -2176,10 +2192,6 @@ def honeypot(self, request, pk): segment_honeypots_count = len(segment_honeypots) - db_task_frames: dict[int, models.Image] = { - frame.frame: frame for frame in db_segment.task.data.images.all() - } - frame_selection_method = input_data['frame_selection_method'] if frame_selection_method == models.JobFrameSelectionMethod.MANUAL: task_honeypot_frame_map: dict[str, int] = { @@ -2268,9 +2280,8 @@ def honeypot(self, request, pk): for updated_segment_frame_id in requested_frame_ids ) - # TODO: maybe there is better way to regenerate chunks - # (e.g. replace specific files directly) - media_cache = MediaCache() + # TODO: replace specific files directly in chunks + media_cache = MediaCache(dimension=models.DimensionType(db_segment.task.dimension)) for chunk_id in updated_segment_chunk_ids: for quality in FrameProvider.Quality.__members__.values(): media_cache.remove_task_chunk( From 77dea65007b754cc86d7f86d7efbc392faa3f03b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 17 Jul 2024 16:20:08 +0300 Subject: [PATCH 007/227] t --- cvat/apps/honeypots/__init__.py | 0 cvat/apps/honeypots/apps.py | 17 +++ cvat/apps/honeypots/migrations/__init__.py | 0 cvat/apps/honeypots/pyproject.toml | 12 ++ cvat/apps/honeypots/reports.py | 29 +++++ .../honeypots/rules/honeypot_reports.rego | 118 ++++++++++++++++++ .../honeypots/rules/honeypot_settings.rego | 104 +++++++++++++++ cvat/apps/honeypots/serializers.py | 13 ++ cvat/apps/honeypots/urls.py | 16 +++ cvat/apps/honeypots/views.py | 64 ++++++++++ 10 files changed, 373 insertions(+) create mode 100644 cvat/apps/honeypots/__init__.py create mode 100644 cvat/apps/honeypots/apps.py create mode 100644 cvat/apps/honeypots/migrations/__init__.py create mode 100644 cvat/apps/honeypots/pyproject.toml create mode 100644 cvat/apps/honeypots/reports.py create mode 100644 cvat/apps/honeypots/rules/honeypot_reports.rego create mode 100644 cvat/apps/honeypots/rules/honeypot_settings.rego create mode 100644 cvat/apps/honeypots/serializers.py create mode 100644 cvat/apps/honeypots/urls.py create mode 100644 cvat/apps/honeypots/views.py diff --git a/cvat/apps/honeypots/__init__.py b/cvat/apps/honeypots/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cvat/apps/honeypots/apps.py b/cvat/apps/honeypots/apps.py new file mode 100644 index 000000000000..487a127e8acf --- /dev/null +++ b/cvat/apps/honeypots/apps.py @@ -0,0 +1,17 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from django.apps import AppConfig + + +class HoneypotsConfig(AppConfig): + name = "cvat.apps.honeypots" + + def ready(self) -> None: + from cvat.apps.iam.permissions import load_app_permissions + + load_app_permissions(self) + + # Required to define signals in the application + from . import signals # pylint: disable=unused-import diff --git a/cvat/apps/honeypots/migrations/__init__.py b/cvat/apps/honeypots/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cvat/apps/honeypots/pyproject.toml b/cvat/apps/honeypots/pyproject.toml new file mode 100644 index 000000000000..567b78362580 --- /dev/null +++ b/cvat/apps/honeypots/pyproject.toml @@ -0,0 +1,12 @@ +[tool.isort] +profile = "black" +forced_separate = ["tests"] +line_length = 100 +skip_gitignore = true # align tool behavior with Black +known_first_party = ["cvat"] + +# Can't just use a pyproject in the root dir, so duplicate +# https://github.com/psf/black/issues/2863 +[tool.black] +line-length = 100 +target-version = ['py38'] diff --git a/cvat/apps/honeypots/reports.py b/cvat/apps/honeypots/reports.py new file mode 100644 index 000000000000..1c16302b3ac8 --- /dev/null +++ b/cvat/apps/honeypots/reports.py @@ -0,0 +1,29 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from dataclasses import dataclass +from typing import NewType + + +RqId = NewType("RqId", str) + + +{ + "validation_frame_ids": [1, 4, 5], + "inactive_validation_frames": [4], + "jobs": [ + { + "id": 1, + "validation_frames": [1, 4] + } + ], +} + + +class ReportManager: + def schedule_report_creation_job(self, task: int) -> RqId: + raise NotImplementedError + + def create_report(self, task: int) -> HoneypotsReport: + pass diff --git a/cvat/apps/honeypots/rules/honeypot_reports.rego b/cvat/apps/honeypots/rules/honeypot_reports.rego new file mode 100644 index 000000000000..73d5ce3307bc --- /dev/null +++ b/cvat/apps/honeypots/rules/honeypot_reports.rego @@ -0,0 +1,118 @@ +package honeypots + +import rego.v1 + +import data.utils +import data.organizations + +# input: { +# "scope": <"view"|"list"|"create"|"view:status"> or null, +# "auth": { +# "user": { +# "id": , +# "privilege": <"admin"|"business"|"user"|"worker"> or null +# }, +# "organization": { +# "id": , +# "owner": { +# "id": +# }, +# "user": { +# "role": <"owner"|"maintainer"|"supervisor"|"worker"> or null +# } +# } or null, +# }, +# "resource": { +# "id": , +# "owner": { "id": }, +# "organization": { "id": } or null, +# "task": { +# "id": , +# "owner": { "id": }, +# "assignee": { "id": }, +# "organization": { "id": } or null, +# } or null, +# "project": { +# "id": , +# "owner": { "id": }, +# "assignee": { "id": }, +# "organization": { "id": } or null, +# } or null, +# } +# } + +default allow := false + +allow if { + utils.is_admin +} + +allow if { + input.scope == utils.LIST + utils.is_sandbox +} + +allow if { + input.scope == utils.LIST + organizations.is_member +} + +filter := [] if { # Django Q object to filter list of entries + utils.is_admin + utils.is_sandbox +} else := qobject if { + utils.is_admin + utils.is_organization + org := input.auth.organization + qobject := [ + {"job__segment__task__organization": org.id}, + {"job__segment__task__project__organization": org.id}, "|", + {"task__organization": org.id}, "|", + {"task__project__organization": org.id}, "|", + ] +} else := qobject if { + utils.is_sandbox + user := input.auth.user + qobject := [ + {"job__segment__task__owner_id": user.id}, + {"job__segment__task__assignee_id": user.id}, "|", + {"job__segment__task__project__owner_id": user.id}, "|", + {"job__segment__task__project__assignee_id": user.id}, "|", + {"task__owner_id": user.id}, "|", + {"task__assignee_id": user.id}, "|", + {"task__project__owner_id": user.id}, "|", + {"task__project__assignee_id": user.id}, "|", + ] +} else := qobject if { + utils.is_organization + utils.has_perm(utils.USER) + organizations.has_perm(organizations.MAINTAINER) + org := input.auth.organization + qobject := [ + {"job__segment__task__organization": org.id}, + {"job__segment__task__project__organization": org.id}, "|", + {"task__organization": org.id}, "|", + {"task__project__organization": org.id}, "|", + ] +} else := qobject if { + organizations.has_perm(organizations.WORKER) + user := input.auth.user + org := input.auth.organization + qobject := [ + {"job__segment__task__organization": org.id}, + {"job__segment__task__project__organization": org.id}, "|", + {"task__organization": org.id}, "|", + {"task__project__organization": org.id}, "|", + + {"job__segment__task__owner_id": user.id}, + {"job__segment__task__assignee_id": user.id}, "|", + {"job__segment__task__project__owner_id": user.id}, "|", + {"job__segment__task__project__assignee_id": user.id}, "|", + {"task__owner_id": user.id}, "|", + {"task__assignee_id": user.id}, "|", + {"task__project__owner_id": user.id}, "|", + {"task__project__assignee_id": user.id}, "|", + + "&" + ] +} diff --git a/cvat/apps/honeypots/rules/honeypot_settings.rego b/cvat/apps/honeypots/rules/honeypot_settings.rego new file mode 100644 index 000000000000..820798c62384 --- /dev/null +++ b/cvat/apps/honeypots/rules/honeypot_settings.rego @@ -0,0 +1,104 @@ +package honeypots + +import rego.v1 + +import data.utils +import data.organizations + +# input: { +# "scope": <"view"> or null, +# "auth": { +# "user": { +# "id": , +# "privilege": <"admin"|"business"|"user"|"worker"> or null +# }, +# "organization": { +# "id": , +# "owner": { +# "id": +# }, +# "user": { +# "role": <"owner"|"maintainer"|"supervisor"|"worker"> or null +# } +# } or null, +# }, +# "resource": { +# "id": , +# "owner": { "id": }, +# "organization": { "id": } or null, +# "task": { +# "id": , +# "owner": { "id": }, +# "assignee": { "id": }, +# "organization": { "id": } or null, +# } or null, +# "project": { +# "id": , +# "owner": { "id": }, +# "assignee": { "id": }, +# "organization": { "id": } or null, +# } or null, +# } +# } + +default allow := false + +allow if { + utils.is_admin +} + +allow if { + input.scope == utils.LIST + utils.is_sandbox +} + +allow if { + input.scope == utils.LIST + organizations.is_member +} + +filter := [] if { # Django Q object to filter list of entries + utils.is_admin + utils.is_sandbox +} else := qobject if { + utils.is_admin + utils.is_organization + org := input.auth.organization + qobject := [ + {"task__organization": org.id}, + {"task__project__organization": org.id}, "|", + ] +} else := qobject if { + utils.is_sandbox + user := input.auth.user + qobject := [ + {"task__owner_id": user.id}, + {"task__assignee_id": user.id}, "|", + {"task__project__owner_id": user.id}, "|", + {"task__project__assignee_id": user.id}, "|", + ] +} else := qobject if { + utils.is_organization + utils.has_perm(utils.USER) + organizations.has_perm(organizations.MAINTAINER) + org := input.auth.organization + qobject := [ + {"task__organization": org.id}, + {"task__project__organization": org.id}, "|", + ] +} else := qobject if { + organizations.has_perm(organizations.WORKER) + user := input.auth.user + org := input.auth.organization + qobject := [ + {"task__organization": org.id}, + {"task__project__organization": org.id}, "|", + + {"task__owner_id": user.id}, + {"task__assignee_id": user.id}, "|", + {"task__project__owner_id": user.id}, "|", + {"task__project__assignee_id": user.id}, "|", + + "&" + ] +} diff --git a/cvat/apps/honeypots/serializers.py b/cvat/apps/honeypots/serializers.py new file mode 100644 index 000000000000..5846a0b70352 --- /dev/null +++ b/cvat/apps/honeypots/serializers.py @@ -0,0 +1,13 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from rest_framework import serializers + + +class HoneypotsReportCreateSerializer(serializers.Serializer): + task_id = serializers.IntegerField() + + +class HoneypotsReportSummarySerializer(serializers.Serializer): + pass diff --git a/cvat/apps/honeypots/urls.py b/cvat/apps/honeypots/urls.py new file mode 100644 index 000000000000..758136b451a4 --- /dev/null +++ b/cvat/apps/honeypots/urls.py @@ -0,0 +1,16 @@ +# Copyright (C) 2023 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from django.urls import include, path +from rest_framework import routers + +from cvat.apps.honeypots import views + +router = routers.DefaultRouter(trailing_slash=False) +router.register("reports", views.HoneypotsReportViewSet, basename="honeypot_reports") + +urlpatterns = [ + # entry point for API + path("honeypots/", include(router.urls)), +] diff --git a/cvat/apps/honeypots/views.py b/cvat/apps/honeypots/views.py new file mode 100644 index 000000000000..516df76f8242 --- /dev/null +++ b/cvat/apps/honeypots/views.py @@ -0,0 +1,64 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import textwrap + +from django.db.models import Q +from django.http import HttpResponse +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, +) +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.response import Response + +from cvat.apps.engine.mixins import PartialUpdateModelMixin +from cvat.apps.engine.models import Task +from cvat.apps.engine.serializers import RqIdSerializer +from cvat.apps.engine.utils import get_server_url +from cvat.apps.honeypots.serializers import ( + HoneypotsReportCreateSerializer, + HoneypotsReportSummarySerializer, +) +from cvat.apps.honeypots.reports import ReportManager + + +@extend_schema(tags=["honeypots"]) +class HoneypotsReportViewSet( + viewsets.GenericViewSet, + mixins.CreateModelMixin, +): + # TODO: take from requests API + + def get_serializer_class(self): + # a separate method is required for drf-spectacular to work + return HoneypotsReportSummarySerializer + + @extend_schema( + operation_id="honeypots_create_report", + summary="Create a honeypots report", + request=HoneypotsReportCreateSerializer(required=False), + responses={ + "201": HoneypotsReportSummarySerializer, + "400": OpenApiResponse( + description="Invalid or failed request, check the response data for details" + ), + }, + ) + def create(self, request, *args, **kwargs): + self.check_permissions(request) + + request_serializer = HoneypotsReportCreateSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + request_data = request_serializer.validated_data + + report_manager = ReportManager() + report = report_manager.create_report(task_id=request_data.task_id) + + return Response(report, status=status.HTTP_200_OK) From b32a9ebd3b79953954bb9c5b741a656939e1cc7b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 25 Jul 2024 19:39:14 +0300 Subject: [PATCH 008/227] Update frame provider and media cache --- cvat/apps/engine/cache.py | 435 +++++++++++++++++---------- cvat/apps/engine/frame_provider.py | 367 +++++++++++++--------- cvat/apps/engine/media_extractors.py | 4 + cvat/apps/engine/pyproject.toml | 12 + 4 files changed, 517 insertions(+), 301 deletions(-) create mode 100644 cvat/apps/engine/pyproject.toml diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 2603c2fd5a13..988e76761214 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -1,68 +1,88 @@ # Copyright (C) 2020-2022 Intel Corporation -# Copyright (C) 2022-2023 CVAT.ai Corporation +# Copyright (C) 2022-2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT +from __future__ import annotations + import io import os -import zipfile -from datetime import datetime, timezone -from io import BytesIO +import pickle # nosec import shutil import tempfile +import zipfile import zlib - -from typing import Optional, Tuple +from contextlib import contextmanager +from datetime import datetime, timezone +from typing import Any, Callable, Optional, Sequence, Tuple, Type import cv2 import PIL.Image -import pickle # nosec +import PIL.ImageOps from django.conf import settings from django.core.cache import caches from rest_framework.exceptions import NotFound, ValidationError -from cvat.apps.engine.cloud_provider import (Credentials, - db_storage_to_storage_instance, - get_cloud_storage_instance) +from cvat.apps.engine import models +from cvat.apps.engine.cloud_provider import ( + Credentials, + db_storage_to_storage_instance, + get_cloud_storage_instance, +) from cvat.apps.engine.log import ServerLogManager -from cvat.apps.engine.media_extractors import (ImageDatasetManifestReader, - Mpeg4ChunkWriter, - Mpeg4CompressedChunkWriter, - VideoDatasetManifestReader, - ZipChunkWriter, - ZipCompressedChunkWriter) +from cvat.apps.engine.media_extractors import ( + FrameQuality, + IChunkWriter, + ImageDatasetManifestReader, + Mpeg4ChunkWriter, + Mpeg4CompressedChunkWriter, + VideoDatasetManifestReader, + ZipChunkWriter, + ZipCompressedChunkWriter, +) from cvat.apps.engine.mime_types import mimetypes -from cvat.apps.engine.models import (DataChoice, DimensionType, Job, Image, - StorageChoice, CloudStorage) from cvat.apps.engine.utils import md5_hash, preload_images from utils.dataset_manifest import ImageManifestManager slogger = ServerLogManager(__name__) + +DataWithMime = Tuple[io.BytesIO, str] +_CacheItem = Tuple[io.BytesIO, str, int] + + class MediaCache: - def __init__(self, dimension=DimensionType.DIM_2D): - self._dimension = dimension - self._cache = caches['media'] - - def _get_or_set_cache_item(self, key, create_function): - def create_item(): - slogger.glob.info(f'Starting to prepare chunk: key {key}') - item = create_function() - slogger.glob.info(f'Ending to prepare chunk: key {key}') - - if item[0]: - item = (item[0], item[1], zlib.crc32(item[0].getbuffer())) + def __init__(self) -> None: + self._cache = caches["media"] + + # TODO migrate keys (check if they will be removed) + + def get_checksum(self, value: bytes) -> int: + return zlib.crc32(value) + + def _get_or_set_cache_item( + self, key: str, create_callback: Callable[[], DataWithMime] + ) -> DataWithMime: + 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}") + + if item_data[0]: + item = (item_data[0], item_data[1], self.get_checksum(item_data[0].getbuffer())) self._cache.set(key, item) + else: + item = (item_data[0], item_data[1], None) return item - slogger.glob.info(f'Starting to get chunk from cache: key {key}') + slogger.glob.info(f"Starting to get chunk from cache: key {key}") try: item = self._cache.get(key) except pickle.UnpicklingError: - slogger.glob.error(f'Unable to get item from cache: key {key}', exc_info=True) + 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)}') + slogger.glob.info(f"Ending to get chunk from cache: key {key}, is_cached {bool(item)}") if not item: item = create_item() @@ -70,113 +90,130 @@ def create_item(): # 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 != zlib.crc32(item_data): - slogger.glob.info(f'Recreating cache item {key} due to checksum mismatch') + if item_checksum != self.get_checksum(item_data): + slogger.glob.info(f"Recreating cache item {key} due to checksum mismatch") item = create_item() return item[0], item[1] - def get_task_chunk_data_with_mime(self, chunk_number, quality, db_data): - item = self._get_or_set_cache_item( - key=f'{db_data.id}_{chunk_number}_{quality}', - create_function=lambda: self._prepare_task_chunk(db_data, quality, chunk_number), - ) + def _get(self, key: str) -> Optional[DataWithMime]: + slogger.glob.info(f"Starting to get chunk from cache: key {key}") + try: + 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)}") return item - def get_selective_job_chunk_data_with_mime(self, chunk_number, quality, job): - item = self._get_or_set_cache_item( - key=f'job_{job.id}_{chunk_number}_{quality}', - create_function=lambda: self.prepare_selective_job_chunk(job, quality, chunk_number), + def get_segment_chunk( + self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality + ) -> DataWithMime: + return self._get_or_set_cache_item( + key=f"segment_{db_segment.id}_{chunk_number}_{quality}", + create_callback=lambda: self.prepare_segment_chunk( + db_segment, chunk_number, quality=quality + ), ) - return item - - def get_local_preview_with_mime(self, frame_number, db_data): - item = self._get_or_set_cache_item( - key=f'data_{db_data.id}_{frame_number}_preview', - create_function=lambda: self._prepare_local_preview(frame_number, db_data), + def get_selective_job_chunk( + self, db_job: models.Job, chunk_number: int, *, quality: FrameQuality + ) -> DataWithMime: + return self._get_or_set_cache_item( + key=f"job_{db_job.id}_{chunk_number}_{quality}", + create_callback=lambda: self.prepare_masked_range_segment_chunk( + db_job.segment, chunk_number, quality=quality + ), ) - return item - - def get_cloud_preview_with_mime( - self, - db_storage: CloudStorage, - ) -> Optional[Tuple[io.BytesIO, str]]: - key = f'cloudstorage_{db_storage.id}_preview' - return self._cache.get(key) - - def get_or_set_cloud_preview_with_mime( - self, - db_storage: CloudStorage, - ) -> Tuple[io.BytesIO, str]: - key = f'cloudstorage_{db_storage.id}_preview' - - item = self._get_or_set_cache_item( - key, create_function=lambda: self._prepare_cloud_preview(db_storage) + def get_local_preview(self, db_data: models.Data, frame_number: int) -> DataWithMime: + return self._get_or_set_cache_item( + key=f"data_{db_data.id}_{frame_number}_preview", + create_callback=lambda: self._prepare_local_preview(frame_number, db_data), ) - return item + def get_cloud_preview(self, db_storage: models.CloudStorage) -> Optional[DataWithMime]: + return self._get(f"cloudstorage_{db_storage.id}_preview") - def get_frame_context_images(self, db_data, frame_number): - item = self._get_or_set_cache_item( - key=f'context_image_{db_data.id}_{frame_number}', - create_function=lambda: self._prepare_context_image(db_data, frame_number) + def get_or_set_cloud_preview(self, db_storage: models.CloudStorage) -> DataWithMime: + return self._get_or_set_cache_item( + f"cloudstorage_{db_storage.id}_preview", + create_callback=lambda: self._prepare_cloud_preview(db_storage), ) - return item + def get_frame_context_images(self, db_data: models.Data, frame_number: int) -> DataWithMime: + return self._get_or_set_cache_item( + key=f"context_image_{db_data.id}_{frame_number}", + create_callback=lambda: self._prepare_context_image(db_data, frame_number), + ) - @staticmethod - def _get_frame_provider_class(): - from cvat.apps.engine.frame_provider import \ - FrameProvider # TODO: remove circular dependency - return FrameProvider + def get_task_preview(self, db_task: models.Task) -> Optional[DataWithMime]: + return self._get(f"task_{db_task.data_id}_preview") - from contextlib import contextmanager + def get_segment_preview(self, db_segment: models.Segment) -> Optional[DataWithMime]: + return self._get(f"segment_{db_segment.id}_preview") - @staticmethod @contextmanager - def _get_images(db_data, chunk_number, dimension): - images = [] + def _read_raw_frames(self, db_task: models.Task, frames: Sequence[int]): + db_data = db_task.data + + media = [] tmp_dir = None - upload_dir = { - StorageChoice.LOCAL: db_data.get_upload_dirname(), - StorageChoice.SHARE: settings.SHARE_ROOT, - StorageChoice.CLOUD_STORAGE: db_data.get_upload_dirname(), + raw_data_dir = { + models.StorageChoice.LOCAL: db_data.get_upload_dirname(), + models.StorageChoice.SHARE: settings.SHARE_ROOT, + models.StorageChoice.CLOUD_STORAGE: db_data.get_upload_dirname(), }[db_data.storage] - try: - if hasattr(db_data, 'video'): - source_path = os.path.join(upload_dir, db_data.video.path) + dimension = db_task.dimension - reader = VideoDatasetManifestReader(manifest_path=db_data.get_manifest_path(), - source_path=source_path, chunk_number=chunk_number, - chunk_size=db_data.chunk_size, start=db_data.start_frame, - stop=db_data.stop_frame, step=db_data.get_frame_step()) + # TODO + try: + if hasattr(db_data, "video"): + source_path = os.path.join(raw_data_dir, db_data.video.path) + + # TODO: refactor to allow non-manifest videos + reader = VideoDatasetManifestReader( + manifest_path=db_data.get_manifest_path(), + source_path=source_path, + chunk_number=chunk_number, + chunk_size=db_data.chunk_size, + start=db_data.start_frame, + stop=db_data.stop_frame, + step=db_data.get_frame_step(), + ) for frame in reader: - images.append((frame, source_path, None)) + media.append((frame, source_path, None)) else: - reader = ImageDatasetManifestReader(manifest_path=db_data.get_manifest_path(), - chunk_number=chunk_number, chunk_size=db_data.chunk_size, - start=db_data.start_frame, stop=db_data.stop_frame, - step=db_data.get_frame_step()) + reader = ImageDatasetManifestReader( + manifest_path=db_data.get_manifest_path(), + chunk_number=chunk_number, + chunk_size=db_data.chunk_size, + start=db_data.start_frame, + stop=db_data.stop_frame, + step=db_data.get_frame_step(), + ) if db_data.storage == StorageChoice.CLOUD_STORAGE: db_cloud_storage = db_data.cloud_storage - assert db_cloud_storage, 'Cloud storage instance was deleted' + assert db_cloud_storage, "Cloud storage instance was deleted" credentials = Credentials() - credentials.convert_from_db({ - 'type': db_cloud_storage.credentials_type, - 'value': db_cloud_storage.credentials, - }) + credentials.convert_from_db( + { + "type": db_cloud_storage.credentials_type, + "value": db_cloud_storage.credentials, + } + ) details = { - 'resource': db_cloud_storage.resource, - 'credentials': credentials, - 'specific_attributes': db_cloud_storage.get_specific_attributes() + "resource": db_cloud_storage.resource, + "credentials": credentials, + "specific_attributes": db_cloud_storage.get_specific_attributes(), } - cloud_storage_instance = get_cloud_storage_instance(cloud_provider=db_cloud_storage.provider_type, **details) + cloud_storage_instance = get_cloud_storage_instance( + cloud_provider=db_cloud_storage.provider_type, **details + ) - tmp_dir = tempfile.mkdtemp(prefix='cvat') + tmp_dir = tempfile.mkdtemp(prefix="cvat") files_to_download = [] checksums = [] for item in reader: @@ -184,51 +221,95 @@ def _get_images(db_data, chunk_number, dimension): fs_filename = os.path.join(tmp_dir, file_name) files_to_download.append(file_name) - checksums.append(item.get('checksum', None)) - images.append((fs_filename, fs_filename, None)) + checksums.append(item.get("checksum", None)) + media.append((fs_filename, fs_filename, None)) - cloud_storage_instance.bulk_download_to_dir(files=files_to_download, upload_dir=tmp_dir) - images = preload_images(images) + cloud_storage_instance.bulk_download_to_dir( + files=files_to_download, upload_dir=tmp_dir + ) + media = preload_images(media) - for checksum, (_, fs_filename, _) in zip(checksums, images): + for checksum, (_, fs_filename, _) in zip(checksums, media): if checksum and not md5_hash(fs_filename) == checksum: - slogger.cloud_storage[db_cloud_storage.id].warning('Hash sums of files {} do not match'.format(file_name)) + slogger.cloud_storage[db_cloud_storage.id].warning( + "Hash sums of files {} do not match".format(file_name) + ) else: for item in reader: - source_path = os.path.join(upload_dir, f"{item['name']}{item['extension']}") - images.append((source_path, source_path, None)) - if dimension == DimensionType.DIM_2D: - images = preload_images(images) - - yield images + source_path = os.path.join( + raw_data_dir, f"{item['name']}{item['extension']}" + ) + media.append((source_path, source_path, None)) + if dimension == models.DimensionType.DIM_2D: + media = preload_images(media) + + yield media finally: - if db_data.storage == StorageChoice.CLOUD_STORAGE and tmp_dir is not None: + if db_data.storage == models.StorageChoice.CLOUD_STORAGE and tmp_dir is not None: shutil.rmtree(tmp_dir) - def _prepare_task_chunk(self, db_data, quality, chunk_number): - FrameProvider = self._get_frame_provider_class() - - writer_classes = { - FrameProvider.Quality.COMPRESSED : Mpeg4CompressedChunkWriter if db_data.compressed_chunk_type == DataChoice.VIDEO else ZipCompressedChunkWriter, - FrameProvider.Quality.ORIGINAL : Mpeg4ChunkWriter if db_data.original_chunk_type == DataChoice.VIDEO else ZipChunkWriter, + def prepare_segment_chunk( + self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality + ) -> DataWithMime: + 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: + return self.prepare_masked_range_segment_chunk( + db_segment, chunk_number, quality=quality + ) + else: + assert False, f"Unknown segment type {db_segment.type}" + + def prepare_range_segment_chunk( + self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality + ) -> DataWithMime: + db_task = db_segment.task + db_data = db_task.data + + chunk_size = db_data.chunk_size + chunk_frames = db_segment.frame_set[ + chunk_size * chunk_number : chunk_number * (chunk_number + 1) + ] + + writer_classes: dict[FrameQuality, Type[IChunkWriter]] = { + FrameQuality.COMPRESSED: ( + Mpeg4CompressedChunkWriter + if db_data.compressed_chunk_type == models.DataChoice.VIDEO + else ZipCompressedChunkWriter + ), + FrameQuality.ORIGINAL: ( + Mpeg4ChunkWriter + if db_data.original_chunk_type == models.DataChoice.VIDEO + else ZipChunkWriter + ), } - image_quality = 100 if writer_classes[quality] in [Mpeg4ChunkWriter, ZipChunkWriter] else db_data.image_quality - mime_type = 'video/mp4' if writer_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] else 'application/zip' + image_quality = ( + 100 + if writer_classes[quality] in [Mpeg4ChunkWriter, ZipChunkWriter] + else db_data.image_quality + ) + mime_type = ( + "video/mp4" + if writer_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] + else "application/zip" + ) kwargs = {} - if self._dimension == DimensionType.DIM_3D: - kwargs["dimension"] = DimensionType.DIM_3D + if db_segment.task.dimension == models.DimensionType.DIM_3D: + kwargs["dimension"] = models.DimensionType.DIM_3D writer = writer_classes[quality](image_quality, **kwargs) - buff = BytesIO() - with self._get_images(db_data, chunk_number, self._dimension) as images: + buff = io.BytesIO() + with self._read_raw_frames(db_task, frames=chunk_frames) as images: writer.save_as_chunk(images, buff) - buff.seek(0) + buff.seek(0) return buff, mime_type - def prepare_selective_job_chunk(self, db_job: Job, quality, chunk_number: int): + def prepare_masked_range_segment_chunk( + self, db_job: models.Job, quality, chunk_number: int + ) -> DataWithMime: db_data = db_job.segment.task.data FrameProvider = self._get_frame_provider_class() @@ -239,10 +320,10 @@ def prepare_selective_job_chunk(self, db_job: Job, quality, chunk_number: int): chunk_frames = [] writer = ZipCompressedChunkWriter(db_data.image_quality, dimension=self._dimension) - dummy_frame = BytesIO() - PIL.Image.new('RGB', (1, 1)).save(dummy_frame, writer.IMAGE_EXT) + dummy_frame = io.BytesIO() + PIL.Image.new("RGB", (1, 1)).save(dummy_frame, writer.IMAGE_EXT) - if hasattr(db_data, 'video'): + if hasattr(db_data, "video"): frame_size = (db_data.video.width, db_data.video.height) else: frame_size = None @@ -266,27 +347,36 @@ def prepare_selective_job_chunk(self, db_job: Job, quality, chunk_number: int): if frame.size != frame_size: frame = frame.resize(frame_size) - frame_bytes = BytesIO() + frame_bytes = io.BytesIO() frame.save(frame_bytes, writer.IMAGE_EXT) frame_bytes.seek(0) else: # Populate skipped frames with placeholder data, # this is required for video chunk decoding implementation in UI - frame_bytes = BytesIO(dummy_frame.getvalue()) + frame_bytes = io.BytesIO(dummy_frame.getvalue()) if frame_bytes is not None: chunk_frames.append((frame_bytes, None, None)) - buff = BytesIO() - writer.save_as_chunk(chunk_frames, buff, compress_frames=False, - zip_compress_level=1 # these are likely to be many skips in SPECIFIC_FRAMES segments + buff = io.BytesIO() + writer.save_as_chunk( + chunk_frames, + buff, + compress_frames=False, + zip_compress_level=1, # there are likely to be many skips in SPECIFIC_FRAMES segments ) buff.seek(0) - return buff, 'application/zip' + return buff, "application/zip" + + def prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: + if db_segment.task.data.cloud_storage: + return self._prepare_cloud_segment_preview(db_segment) + else: + return self._prepare_local_segment_preview(db_segment) - def _prepare_local_preview(self, frame_number, db_data): + def _prepare_local_preview(self, db_data: models.Data, frame_number: int) -> DataWithMime: FrameProvider = self._get_frame_provider_class() frame_provider = FrameProvider(db_data, self._dimension) buff, mime_type = frame_provider.get_preview(frame_number) @@ -296,28 +386,31 @@ def _prepare_local_preview(self, frame_number, db_data): def _prepare_cloud_preview(self, 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') + raise ValidationError("Cannot get the cloud storage preview. There is no manifest file") preview_path = None for manifest_model in db_storage.manifests.all(): manifest_prefix = os.path.dirname(manifest_model.filename) - full_manifest_path = os.path.join(db_storage.get_storage_dirname(), manifest_model.filename) - if not os.path.exists(full_manifest_path) or \ - datetime.fromtimestamp(os.path.getmtime(full_manifest_path), tz=timezone.utc) < storage.get_file_last_modified(manifest_model.filename): + full_manifest_path = os.path.join( + db_storage.get_storage_dirname(), manifest_model.filename + ) + if not os.path.exists(full_manifest_path) or datetime.fromtimestamp( + os.path.getmtime(full_manifest_path), tz=timezone.utc + ) < storage.get_file_last_modified(manifest_model.filename): storage.download_file(manifest_model.filename, full_manifest_path) manifest = ImageManifestManager( os.path.join(db_storage.get_storage_dirname(), manifest_model.filename), - db_storage.get_storage_dirname() + db_storage.get_storage_dirname(), ) # need to update index manifest.set_index() if not len(manifest): continue preview_info = manifest[0] - preview_filename = ''.join([preview_info['name'], preview_info['extension']]) + preview_filename = "".join([preview_info["name"], preview_info["extension"]]) preview_path = os.path.join(manifest_prefix, preview_filename) break if not preview_path: - msg = 'Cloud storage {} does not contain any images'.format(db_storage.pk) + msg = "Cloud storage {} does not contain any images".format(db_storage.pk) slogger.cloud_storage[db_storage.pk].info(msg) raise NotFound(msg) @@ -326,24 +419,42 @@ def _prepare_cloud_preview(self, db_storage): return buff, mime_type - def _prepare_context_image(self, db_data, frame_number): - zip_buffer = BytesIO() + def prepare_context_images( + self, db_data: models.Data, frame_number: int + ) -> Optional[DataWithMime]: + zip_buffer = io.BytesIO() try: - image = Image.objects.get(data_id=db_data.id, frame=frame_number) - except Image.DoesNotExist: - return None, None - with zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_DEFLATED, False) as zip_file: + image = models.Image.objects.get(data_id=db_data.id, frame=frame_number) + except models.Image.DoesNotExist: + return None + + with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file: if not image.related_files.count(): return None, None - common_path = os.path.commonpath(list(map(lambda x: str(x.path), image.related_files.all()))) + common_path = os.path.commonpath( + list(map(lambda x: str(x.path), image.related_files.all())) + ) for i in image.related_files.all(): path = os.path.realpath(str(i.path)) name = os.path.relpath(str(i.path), common_path) image = cv2.imread(path) - success, result = cv2.imencode('.JPEG', image) + success, result = cv2.imencode(".JPEG", image) if not success: raise Exception('Failed to encode image to ".jpeg" format') - zip_file.writestr(f'{name}.jpg', result.tobytes()) - mime_type = 'application/zip' + zip_file.writestr(f"{name}.jpg", result.tobytes()) + zip_buffer.seek(0) + mime_type = "application/zip" return zip_buffer, mime_type + + +def prepare_preview_image(image: PIL.Image.Image) -> DataWithMime: + PREVIEW_SIZE = (256, 256) + PREVIEW_MIME = "image/jpeg" + + image = PIL.ImageOps.exif_transpose(image) + image.thumbnail(PREVIEW_SIZE) + + output_buf = io.BytesIO() + image.convert("RGB").save(output_buf, format="JPEG") + return image, PREVIEW_MIME diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 4e2f42ef7933..92354723b02e 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -3,26 +3,34 @@ # # SPDX-License-Identifier: MIT +from __future__ import annotations + import math -from enum import Enum -from io import BytesIO import os +from dataclasses import dataclass +from enum import Enum, auto +from io import BytesIO +from typing import Any, Callable, Generic, Iterable, Iterator, Optional, Tuple, TypeVar, Union +import av import cv2 import numpy as np -from PIL import Image, ImageOps +from PIL import Image +from rest_framework.exceptions import ValidationError -from cvat.apps.engine.cache import MediaCache -from cvat.apps.engine.media_extractors import VideoReader, ZipReader +from cvat.apps.engine import models +from cvat.apps.engine.cache import DataWithMime, MediaCache, prepare_preview_image +from cvat.apps.engine.media_extractors import FrameQuality, IMediaReader, VideoReader, ZipReader from cvat.apps.engine.mime_types import mimetypes -from cvat.apps.engine.models import DataChoice, StorageMethodChoice, DimensionType -from rest_framework.exceptions import ValidationError -class RandomAccessIterator: - def __init__(self, iterable): - self.iterable = iterable - self.iterator = None - self.pos = -1 +_T = TypeVar("_T") + + +class _RandomAccessIterator(Iterator[_T]): + def __init__(self, iterable: Iterable[_T]): + self.iterable: Iterable[_T] = iterable + self.iterator: Optional[Iterator[_T]] = None + self.pos: int = -1 def __iter__(self): return self @@ -30,7 +38,7 @@ def __iter__(self): def __next__(self): return self[self.pos + 1] - def __getitem__(self, idx): + def __getitem__(self, idx: int) -> Optional[_T]: assert 0 <= idx if self.iterator is None or idx <= self.pos: self.reset() @@ -47,168 +55,241 @@ def reset(self): def close(self): if self.iterator is not None: - if close := getattr(self.iterator, 'close', None): + if close := getattr(self.iterator, "close", None): close() self.iterator = None self.pos = -1 -class FrameProvider: - VIDEO_FRAME_EXT = '.PNG' - VIDEO_FRAME_MIME = 'image/png' - class Quality(Enum): - COMPRESSED = 0 - ORIGINAL = 100 +class _ChunkLoader: + def __init__( + self, reader_class: IMediaReader, path_getter: Callable[[int], DataWithMime] + ) -> None: + self.chunk_id: Optional[int] = None + self.chunk_reader: Optional[_RandomAccessIterator] = None + self.reader_class = reader_class + self.get_chunk_path = path_getter - class Type(Enum): - BUFFER = 0 - PIL = 1 - NUMPY_ARRAY = 2 + def load(self, chunk_id: int) -> _RandomAccessIterator[Tuple[Any, str, int]]: + if self.chunk_id != chunk_id: + self.unload() - class ChunkLoader: - def __init__(self, reader_class, path_getter): - self.chunk_id = None - self.chunk_reader = None - self.reader_class = reader_class - self.get_chunk_path = path_getter - - def load(self, chunk_id): - if self.chunk_id != chunk_id: - self.unload() - - self.chunk_id = chunk_id - self.chunk_reader = RandomAccessIterator( - self.reader_class([self.get_chunk_path(chunk_id)])) - return self.chunk_reader - - def unload(self): - self.chunk_id = None - if self.chunk_reader: - self.chunk_reader.close() - self.chunk_reader = None - - class BuffChunkLoader(ChunkLoader): - def __init__(self, reader_class, path_getter, quality, db_data): - super().__init__(reader_class, path_getter) - self.quality = quality - self.db_data = db_data - - def load(self, chunk_id): - if self.chunk_id != chunk_id: - self.chunk_id = chunk_id - self.chunk_reader = RandomAccessIterator( - self.reader_class([self.get_chunk_path(chunk_id, self.quality, self.db_data)[0]])) - return self.chunk_reader - - def __init__(self, db_data, dimension=DimensionType.DIM_2D): - self._db_data = db_data - self._dimension = dimension - self._loaders = {} - - reader_class = { - DataChoice.IMAGESET: ZipReader, - DataChoice.VIDEO: VideoReader, - } + self.chunk_id = chunk_id + self.chunk_reader = _RandomAccessIterator( + self.reader_class([self.get_chunk_path(chunk_id)]) + ) + return self.chunk_reader - if db_data.storage_method == StorageMethodChoice.CACHE: - cache = MediaCache(dimension=dimension) + def unload(self): + self.chunk_id = None + if self.chunk_reader: + self.chunk_reader.close() + self.chunk_reader = None - self._loaders[self.Quality.COMPRESSED] = self.BuffChunkLoader( - reader_class[db_data.compressed_chunk_type], - cache.get_task_chunk_data_with_mime, - self.Quality.COMPRESSED, - self._db_data) - self._loaders[self.Quality.ORIGINAL] = self.BuffChunkLoader( - reader_class[db_data.original_chunk_type], - cache.get_task_chunk_data_with_mime, - self.Quality.ORIGINAL, - self._db_data) - else: - self._loaders[self.Quality.COMPRESSED] = self.ChunkLoader( - reader_class[db_data.compressed_chunk_type], - db_data.get_compressed_chunk_path) - self._loaders[self.Quality.ORIGINAL] = self.ChunkLoader( - reader_class[db_data.original_chunk_type], - db_data.get_original_chunk_path) - def __len__(self): - return self._db_data.size +class FrameOutputType(Enum): + BUFFER = auto() + PIL = auto() + NUMPY_ARRAY = auto() - def unload(self): - for loader in self._loaders.values(): - loader.unload() - def _validate_frame_number(self, frame_number): - frame_number_ = int(frame_number) - if frame_number_ < 0 or frame_number_ >= self._db_data.size: - raise ValidationError('Incorrect requested frame number: {}'.format(frame_number_)) +Frame2d = Union[BytesIO, np.ndarray, Image.Image] +Frame3d = BytesIO +AnyFrame = Union[Frame2d, Frame3d] - chunk_number = frame_number_ // self._db_data.chunk_size - frame_offset = frame_number_ % self._db_data.chunk_size - return frame_number_, chunk_number, frame_offset +@dataclass +class DataWithMeta(Generic[_T]): + data: _T + mime: str + checksum: int - def get_chunk_number(self, frame_number): - return int(frame_number) // self._db_data.chunk_size - def _validate_chunk_number(self, chunk_number): - chunk_number_ = int(chunk_number) - if chunk_number_ < 0 or chunk_number_ >= math.ceil(self._db_data.size / self._db_data.chunk_size): - raise ValidationError('requested chunk does not exist') +class _FrameProvider: + VIDEO_FRAME_EXT = ".PNG" + VIDEO_FRAME_MIME = "image/png" - return chunk_number_ + def unload(self): + pass @classmethod - def _av_frame_to_png_bytes(cls, av_frame): + def _av_frame_to_png_bytes(cls, av_frame: av.VideoFrame) -> BytesIO: ext = cls.VIDEO_FRAME_EXT - image = av_frame.to_ndarray(format='bgr24') + image = av_frame.to_ndarray(format="bgr24") success, result = cv2.imencode(ext, image) if not success: raise RuntimeError("Failed to encode image to '%s' format" % (ext)) return BytesIO(result.tobytes()) - def _convert_frame(self, frame, reader_class, out_type): - if out_type == self.Type.BUFFER: + def _convert_frame( + self, frame: Any, reader_class: IMediaReader, out_type: FrameOutputType + ) -> AnyFrame: + if out_type == FrameOutputType.BUFFER: return self._av_frame_to_png_bytes(frame) if reader_class is VideoReader else frame - elif out_type == self.Type.PIL: + elif out_type == FrameOutputType.PIL: return frame.to_image() if reader_class is VideoReader else Image.open(frame) - elif out_type == self.Type.NUMPY_ARRAY: + elif out_type == FrameOutputType.NUMPY_ARRAY: if reader_class is VideoReader: - image = frame.to_ndarray(format='bgr24') + image = frame.to_ndarray(format="bgr24") else: image = np.array(Image.open(frame)) if len(image.shape) == 3 and image.shape[2] in {3, 4}: - image[:, :, :3] = image[:, :, 2::-1] # RGB to BGR + image[:, :, :3] = image[:, :, 2::-1] # RGB to BGR return image else: - raise RuntimeError('unsupported output type') + raise RuntimeError("unsupported output type") + + +class TaskFrameProvider(_FrameProvider): + def __init__(self, db_task: models.Task) -> None: + self._db_task = db_task + + def _validate_frame_number(self, frame_number: int) -> int: + if not (0 <= frame_number < self._db_task.data.size): + raise ValidationError(f"Incorrect requested frame number: {frame_number}") + + return frame_number + + def get_preview(self) -> DataWithMeta[BytesIO]: + return self._get_segment_frame_provider(self._db_task.data.start_frame).get_preview() + + def get_chunk( + self, chunk_number: int, *, quality: FrameQuality = FrameQuality.ORIGINAL + ) -> DataWithMeta[BytesIO]: + # TODO: return a joined chunk. Find a solution for segment boundary video chunks + return self._get_segment_frame_provider(frame_number).get_frame( + frame_number, quality=quality, out_type=out_type + ) + + def get_frame( + self, + frame_number: int, + *, + quality: FrameQuality = FrameQuality.ORIGINAL, + out_type: FrameOutputType = FrameOutputType.BUFFER, + ) -> AnyFrame: + return self._get_segment_frame_provider(frame_number).get_frame( + frame_number, quality=quality, out_type=out_type + ) + + def iterate_frames( + self, + *, + start_frame: Optional[int] = None, + stop_frame: Optional[int] = None, + quality: FrameQuality = FrameQuality.ORIGINAL, + out_type: FrameOutputType = FrameOutputType.BUFFER, + ) -> Iterator[AnyFrame]: + # TODO: optimize segment access + for idx in range(start_frame, (stop_frame + 1) if stop_frame else None): + yield self.get_frame(idx, quality=quality, out_type=out_type) - def get_preview(self, frame_number): - PREVIEW_SIZE = (256, 256) - PREVIEW_MIME = 'image/jpeg' + def _get_segment(self, validated_frame_number: int) -> models.Segment: + return next( + s + for s in self._db_task.segments.all() + if s.type == models.SegmentType.RANGE + if validated_frame_number in s.frame_set + ) - if self._dimension == DimensionType.DIM_3D: - # TODO - preview = Image.open(os.path.join(os.path.dirname(__file__), 'assets/3d_preview.jpeg')) + def _get_segment_frame_provider(self, frame_number: int) -> _SegmentFrameProvider: + segment = self._get_segment(self._validate_frame_number(frame_number)) + return _SegmentFrameProvider( + next(job for job in segment.jobs.all() if job.type == models.JobType.ANNOTATION) + ) + + +class _SegmentFrameProvider(_FrameProvider): + def __init__(self, db_segment: models.Segment) -> None: + super().__init__() + self._db_segment = db_segment + + db_data = db_segment.task.data + + reader_class: dict[models.DataChoice, IMediaReader] = { + models.DataChoice.IMAGESET: ZipReader, + models.DataChoice.VIDEO: VideoReader, + } + + self._loaders: dict[FrameQuality, _ChunkLoader] = {} + if db_data.storage_method == models.StorageMethodChoice.CACHE: + cache = MediaCache() + + self._loaders[FrameQuality.COMPRESSED] = _ChunkLoader( + reader_class[db_data.compressed_chunk_type], + lambda chunk_idx: cache.get_segment_chunk( + db_segment, chunk_idx, quality=FrameQuality.COMPRESSED + ), + ) + + self._loaders[FrameQuality.ORIGINAL] = _ChunkLoader( + reader_class[db_data.original_chunk_type], + lambda chunk_idx: cache.get_segment_chunk( + db_segment, chunk_idx, quality=FrameQuality.ORIGINAL + ), + ) else: - preview, _ = self.get_frame(frame_number, self.Quality.COMPRESSED, self.Type.PIL) + self._loaders[FrameQuality.COMPRESSED] = _ChunkLoader( + reader_class[db_data.compressed_chunk_type], db_data.get_compressed_chunk_path + ) - preview = ImageOps.exif_transpose(preview) - preview.thumbnail(PREVIEW_SIZE) + self._loaders[FrameQuality.ORIGINAL] = _ChunkLoader( + reader_class[db_data.original_chunk_type], db_data.get_original_chunk_path + ) - output_buf = BytesIO() - preview.convert('RGB').save(output_buf, format="JPEG") + def unload(self): + for loader in self._loaders.values(): + loader.unload() + + def __len__(self): + return self._db_segment.frame_count + + def _validate_frame_number(self, frame_number: int) -> Tuple[int, int, int]: + # TODO: check for masked range segment + + if frame_number not in self._db_segment.frame_set: + raise ValidationError(f"Incorrect requested frame number: {frame_number}") - return output_buf, PREVIEW_MIME + chunk_number, frame_position = divmod(frame_number, self._db_segment.task.data.chunk_size) + return frame_number, chunk_number, frame_position - def get_chunk(self, chunk_number, quality=Quality.ORIGINAL): - chunk_number = self._validate_chunk_number(chunk_number) - if self._db_data.storage_method == StorageMethodChoice.CACHE: - return self._loaders[quality].get_chunk_path(chunk_number, quality, self._db_data) - return self._loaders[quality].get_chunk_path(chunk_number) + def get_chunk_number(self, frame_number: int) -> int: + return int(frame_number) // self._db_segment.task.data.chunk_size - def get_frame(self, frame_number, quality=Quality.ORIGINAL, - out_type=Type.BUFFER): + def _validate_chunk_number(self, chunk_number: int) -> int: + segment_size = len(self._db_segment.frame_count) + if chunk_number < 0 or chunk_number >= math.ceil( + segment_size / self._db_segment.task.data.chunk_size + ): + raise ValidationError("requested chunk does not exist") + + return chunk_number + + def get_preview(self) -> DataWithMeta[BytesIO]: + if self._db_segment.task.dimension == models.DimensionType.DIM_3D: + # TODO + preview = Image.open(os.path.join(os.path.dirname(__file__), "assets/3d_preview.jpeg")) + else: + preview, _ = self.get_frame( + min(self._db_segment.frame_set), + frame_number=FrameQuality.COMPRESSED, + out_type=FrameOutputType.PIL, + ) + + return prepare_preview_image(preview) + + def get_chunk( + self, chunk_number: int, *, quality: FrameQuality = FrameQuality.ORIGINAL + ) -> DataWithMeta[BytesIO]: + return self._loaders[quality].get_chunk_path(self._validate_chunk_number(chunk_number)) + + def get_frame( + self, + frame_number: int, + *, + quality: FrameQuality = FrameQuality.ORIGINAL, + out_type: FrameOutputType = FrameOutputType.BUFFER, + ) -> AnyFrame: _, chunk_number, frame_offset = self._validate_frame_number(frame_number) loader = self._loaders[quality] chunk_reader = loader.load(chunk_number) @@ -219,10 +300,18 @@ def get_frame(self, frame_number, quality=Quality.ORIGINAL, return (frame, self.VIDEO_FRAME_MIME) return (frame, mimetypes.guess_type(frame_name)[0]) - def get_frames(self, start_frame, stop_frame, quality=Quality.ORIGINAL, out_type=Type.BUFFER): - for idx in range(start_frame, stop_frame): + def iterate_frames( + self, + *, + start_frame: Optional[int] = None, + stop_frame: Optional[int] = None, + quality: FrameQuality = FrameQuality.ORIGINAL, + out_type: FrameOutputType = FrameOutputType.BUFFER, + ) -> Iterator[AnyFrame]: + for idx in range(start_frame, (stop_frame + 1) if stop_frame else None): yield self.get_frame(idx, quality=quality, out_type=out_type) - @property - def data_id(self): - return self._db_data.id + +class JobFrameProvider(_SegmentFrameProvider): + def __init__(self, db_job: models.Job) -> None: + super().__init__(db_job.segment) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 9a352c3b930c..7ca6ff0ed54d 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -45,6 +45,10 @@ class ORIENTATION(IntEnum): MIRROR_HORIZONTAL_90_ROTATED=7 NORMAL_270_ROTATED=8 +class FrameQuality(IntEnum): + COMPRESSED = 0 + ORIGINAL = 100 + def get_mime(name): for type_name, type_def in MEDIA_TYPES.items(): if type_def['has_mime_type'](name): diff --git a/cvat/apps/engine/pyproject.toml b/cvat/apps/engine/pyproject.toml new file mode 100644 index 000000000000..567b78362580 --- /dev/null +++ b/cvat/apps/engine/pyproject.toml @@ -0,0 +1,12 @@ +[tool.isort] +profile = "black" +forced_separate = ["tests"] +line_length = 100 +skip_gitignore = true # align tool behavior with Black +known_first_party = ["cvat"] + +# Can't just use a pyproject in the root dir, so duplicate +# https://github.com/psf/black/issues/2863 +[tool.black] +line-length = 100 +target-version = ['py38'] From cb4ff9394eb37638de97cbc4365d1032a4d279a8 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 25 Jul 2024 19:39:21 +0300 Subject: [PATCH 009/227] t --- cvat/apps/engine/task.py | 408 +++++++++++++++++++++----------------- cvat/apps/engine/views.py | 21 +- 2 files changed, 237 insertions(+), 192 deletions(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index c44f01e1f354..866e3d0c9133 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1,32 +1,34 @@ # Copyright (C) 2018-2022 Intel Corporation -# Copyright (C) 2022-2023 CVAT.ai Corporation +# Copyright (C) 2022-2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT import itertools import fnmatch import os -from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Union, Iterable -from rest_framework.serializers import ValidationError import rq import re import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Sequence, Union, Iterable from urllib import parse as urlparse from urllib import request as urlrequest -import django_rq import concurrent.futures import queue +import django_rq from django.conf import settings from django.db import transaction from django.http import HttpRequest -from datetime import datetime, timezone -from pathlib import Path +from rest_framework.serializers import ValidationError from cvat.apps.engine import models from cvat.apps.engine.log import ServerLogManager -from cvat.apps.engine.media_extractors import (MEDIA_TYPES, ImageListReader, Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter, - ValidateDimension, ZipChunkWriter, ZipCompressedChunkWriter, get_mime, sort) +from cvat.apps.engine.media_extractors import ( + MEDIA_TYPES, IMediaReader, ImageListReader, Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter, + ValidateDimension, ZipChunkWriter, ZipCompressedChunkWriter, get_mime, sort +) from cvat.apps.engine.utils import ( av_scan_paths,get_rq_job_meta, define_dependent_job, get_rq_lock_by_user, preload_images ) @@ -70,6 +72,8 @@ def create( class SegmentParams(NamedTuple): start_frame: int stop_frame: int + type: models.SegmentType = models.SegmentType.RANGE + frames: Optional[Sequence[int]] = [] class SegmentsParams(NamedTuple): segments: Iterator[SegmentParams] @@ -126,10 +130,14 @@ def _segments(): # It is assumed here that files are already saved ordered in the task # Here we just need to create segments by the job sizes start_frame = 0 - for jf in job_file_mapping: - segment_size = len(jf) + for job_files in job_file_mapping: + segment_size = len(job_files) stop_frame = start_frame + segment_size - 1 - yield SegmentParams(start_frame, stop_frame) + yield SegmentParams( + start_frame=start_frame, + stop_frame=stop_frame, + type=models.SegmentType.RANGE, + ) start_frame = stop_frame + 1 @@ -152,31 +160,39 @@ def _segments(): ) segments = ( - SegmentParams(start_frame, min(start_frame + segment_size - 1, data_size - 1)) + SegmentParams( + start_frame=start_frame, + stop_frame=min(start_frame + segment_size - 1, data_size - 1), + type=models.SegmentType.RANGE + ) for start_frame in range(0, data_size - overlap, segment_size - overlap) ) return SegmentsParams(segments, segment_size, overlap) -def _save_task_to_db(db_task: models.Task, *, job_file_mapping: Optional[JobFileMapping] = None): - job = rq.get_current_job() - job.meta['status'] = 'Task is being saved in database' - job.save_meta() +def _save_task_to_db( + db_task: models.Task, + *, + job_file_mapping: Optional[JobFileMapping] = None, +): + rq_job = rq.get_current_job() + rq_job.meta['status'] = 'Task is being saved in database' + rq_job.save_meta() segments, segment_size, overlap = _get_task_segment_data( - db_task=db_task, job_file_mapping=job_file_mapping + db_task=db_task, job_file_mapping=job_file_mapping, ) db_task.segment_size = segment_size db_task.overlap = overlap - for segment_idx, (start_frame, stop_frame) in enumerate(segments): - slogger.glob.info("New segment for task #{}: idx = {}, start_frame = {}, \ - stop_frame = {}".format(db_task.id, segment_idx, start_frame, stop_frame)) + for segment_idx, segment_params in enumerate(segments): + slogger.glob.info( + "New segment for task #{task_id}: idx = {segment_idx}, start_frame = {start_frame}, \ + stop_frame = {stop_frame}".format( + task_id=db_task.id, segment_idx=segment_idx, **segment_params._asdict() + )) - db_segment = models.Segment() - db_segment.task = db_task - db_segment.start_frame = start_frame - db_segment.stop_frame = stop_frame + db_segment = models.Segment(task=db_task, **segment_params._asdict()) db_segment.save() db_job = models.Job(segment=db_segment) @@ -756,7 +772,7 @@ def _update_status(msg: str) -> None: ) # Extract input data - extractor = None + extractor: Optional[IMediaReader] = None manifest_index = _get_manifest_frame_indexer() for media_type, media_files in media.items(): if not media_files: @@ -916,19 +932,6 @@ def _update_status(msg: str) -> None: db_data.compressed_chunk_type = models.DataChoice.VIDEO if task_mode == 'interpolation' and not data['use_zip_chunks'] else models.DataChoice.IMAGESET db_data.original_chunk_type = models.DataChoice.VIDEO if task_mode == 'interpolation' else models.DataChoice.IMAGESET - def update_progress(progress): - progress_animation = '|/-\\' - if not hasattr(update_progress, 'call_counter'): - update_progress.call_counter = 0 - - status_message = 'CVAT is preparing data chunks' - if not progress: - status_message = '{} {}'.format(status_message, progress_animation[update_progress.call_counter]) - job.meta['status'] = status_message - job.meta['task_progress'] = progress or 0. - job.save_meta() - update_progress.call_counter = (update_progress.call_counter + 1) % len(progress_animation) - compressed_chunk_writer_class = Mpeg4CompressedChunkWriter if db_data.compressed_chunk_type == models.DataChoice.VIDEO else ZipCompressedChunkWriter if db_data.original_chunk_type == models.DataChoice.VIDEO: original_chunk_writer_class = Mpeg4ChunkWriter @@ -959,135 +962,220 @@ def update_progress(progress): else: db_data.chunk_size = 36 - video_path = "" - video_size = (0, 0) + # TODO: try to pull up + # replace manifest file (e.g was uploaded 'subdir/manifest.jsonl' or 'some_manifest.jsonl') + if ( + settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE and + manifest_file and not os.path.exists(db_data.get_manifest_path()) + ): + shutil.copyfile(os.path.join(manifest_root, manifest_file), + db_data.get_manifest_path()) + if manifest_root and manifest_root.startswith(db_data.get_upload_dirname()): + os.remove(os.path.join(manifest_root, manifest_file)) + manifest_file = os.path.relpath(db_data.get_manifest_path(), upload_dir) - db_images = [] + video_path: str = "" + video_size: tuple[int, int] = (0, 0) - if settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE: - for media_type, media_files in media.items(): - if not media_files: - continue + images: list[models.Image] = [] - # replace manifest file (e.g was uploaded 'subdir/manifest.jsonl' or 'some_manifest.jsonl') - if manifest_file and not os.path.exists(db_data.get_manifest_path()): - shutil.copyfile(os.path.join(manifest_root, manifest_file), - db_data.get_manifest_path()) - if manifest_root and manifest_root.startswith(db_data.get_upload_dirname()): - os.remove(os.path.join(manifest_root, manifest_file)) - manifest_file = os.path.relpath(db_data.get_manifest_path(), upload_dir) + # Collect media metadata + for media_type, media_files in media.items(): + if not media_files: + continue - if task_mode == MEDIA_TYPES['video']['mode']: + if task_mode == MEDIA_TYPES['video']['mode']: + manifest_is_prepared = False + if manifest_file: try: - manifest_is_prepared = False - if manifest_file: - try: - manifest = VideoManifestValidator(source_path=os.path.join(upload_dir, media_files[0]), - manifest_path=db_data.get_manifest_path()) - manifest.init_index() - manifest.validate_seek_key_frames() - assert len(manifest) > 0, 'No key frames.' - - all_frames = manifest.video_length - video_size = manifest.video_resolution - manifest_is_prepared = True - except Exception as ex: - manifest.remove() - if isinstance(ex, AssertionError): - base_msg = str(ex) - else: - base_msg = 'Invalid manifest file was upload.' - slogger.glob.warning(str(ex)) - _update_status('{} Start prepare a valid manifest file.'.format(base_msg)) - - if not manifest_is_prepared: - _update_status('Start prepare a manifest file') - manifest = VideoManifestManager(db_data.get_manifest_path()) - manifest.link( - media_file=media_files[0], - upload_dir=upload_dir, - chunk_size=db_data.chunk_size - ) - manifest.create() - _update_status('A manifest had been created') + _update_status('Validating the input manifest file') - all_frames = len(manifest.reader) - video_size = manifest.reader.resolution - manifest_is_prepared = True + manifest = VideoManifestValidator( + source_path=os.path.join(upload_dir, media_files[0]), + manifest_path=db_data.get_manifest_path() + ) + manifest.init_index() + manifest.validate_seek_key_frames() - db_data.size = len(range(db_data.start_frame, min(data['stop_frame'] + 1 \ - if data['stop_frame'] else all_frames, all_frames), db_data.get_frame_step())) - video_path = os.path.join(upload_dir, media_files[0]) + if not len(manifest): + raise ValidationError("No key frames found in the manifest") + + all_frames = manifest.video_length + video_size = manifest.video_resolution + manifest_is_prepared = True + except Exception as ex: + manifest.remove() + manifest = None + + slogger.glob.warning(ex, exc_info=True) + if isinstance(ex, (ValidationError, AssertionError)): + _update_status(f'Invalid manifest file was upload: {ex}') + + if ( + settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE + and not manifest_is_prepared + ): + # TODO: check if we can always use video manifest for optimization + try: + _update_status('Preparing a manifest file') + + # TODO: maybe generate manifest in a temp directory + manifest = VideoManifestManager(db_data.get_manifest_path()) + manifest.link( + media_file=media_files[0], + upload_dir=upload_dir, + chunk_size=db_data.chunk_size + ) + manifest.create() + + _update_status('A manifest has been created') + + all_frames = len(manifest.reader) # TODO: check if the field access above and here are equivalent + video_size = manifest.reader.resolution + manifest_is_prepared = True except Exception as ex: - db_data.storage_method = models.StorageMethodChoice.FILE_SYSTEM manifest.remove() - del manifest + manifest = None + + db_data.storage_method = models.StorageMethodChoice.FILE_SYSTEM + base_msg = str(ex) if isinstance(ex, AssertionError) \ else "Uploaded video does not support a quick way of task creating." _update_status("{} The task will be created using the old method".format(base_msg)) - else: # images, archive, pdf - db_data.size = len(extractor) - manifest = ImageManifestManager(db_data.get_manifest_path()) + if not manifest: + all_frames = len(extractor) + video_size = extractor.get_image_size(0) + + db_data.size = len(range( + db_data.start_frame, + min( + data['stop_frame'] + 1 if data['stop_frame'] else all_frames, + all_frames, + ), + db_data.get_frame_step() + )) + video_path = os.path.join(upload_dir, media_files[0]) + else: # images, archive, pdf + db_data.size = len(extractor) + + manifest = None + if settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE: + manifest = ImageManifestManager(db_data.get_manifest_path()) if not manifest.exists: manifest.link( sources=extractor.absolute_source_paths, - meta={ k: {'related_images': related_images[k] } for k in related_images }, + meta={ + k: {'related_images': related_images[k] } + for k in related_images + }, data_dir=upload_dir, DIM_3D=(db_task.dimension == models.DimensionType.DIM_3D), ) manifest.create() else: manifest.init_index() - counter = itertools.count() - for _, chunk_frames in itertools.groupby(extractor.frame_range, lambda x: next(counter) // db_data.chunk_size): - chunk_paths = [(extractor.get_path(i), i) for i in chunk_frames] - img_sizes = [] - - for chunk_path, frame_id in chunk_paths: - properties = manifest[manifest_index(frame_id)] - - # check mapping - if not chunk_path.endswith(f"{properties['name']}{properties['extension']}"): - raise Exception('Incorrect file mapping to manifest content') - - if db_task.dimension == models.DimensionType.DIM_2D and ( - properties.get('width') is not None and - properties.get('height') is not None - ): - resolution = (properties['width'], properties['height']) - elif is_data_in_cloud: - raise Exception( - "Can't find image '{}' width or height info in the manifest" - .format(f"{properties['name']}{properties['extension']}") - ) - else: - resolution = extractor.get_image_size(frame_id) - img_sizes.append(resolution) - - db_images.extend([ - models.Image(data=db_data, - path=os.path.relpath(path, upload_dir), - frame=frame, width=w, height=h) - for (path, frame), (w, h) in zip(chunk_paths, img_sizes) - ]) + + for frame_id in extractor.frame_range: + image_path = extractor.get_path(frame_id) + image_size = None + + if manifest: + image_info = manifest[manifest_index(frame_id)] + + # check mapping + if not image_path.endswith(f"{image_info['name']}{image_info['extension']}"): + raise ValidationError('Incorrect file mapping to manifest content') + + if db_task.dimension == models.DimensionType.DIM_2D and ( + image_info.get('width') is not None and + image_info.get('height') is not None + ): + image_size = (image_info['width'], image_info['height']) + elif is_data_in_cloud: + raise ValidationError( + "Can't find image '{}' width or height info in the manifest" + .format(f"{image_info['name']}{image_info['extension']}") + ) + + if not image_size: + image_size = extractor.get_image_size(frame_id) + + images.append( + models.Image( + data=db_data, + path=os.path.relpath(image_path, upload_dir), + frame=frame_id, + width=image_size[0], + height=image_size[1], + ) + ) + + if db_task.mode == 'annotation': + models.Image.objects.bulk_create(images) + images = models.Image.objects.filter(data_id=db_data.id) + + db_related_files = [ + models.RelatedFile(data=image.data, primary_image=image, path=os.path.join(upload_dir, related_file_path)) + for image in images + for related_file_path in related_images.get(image.path, []) + if not image.is_placeholder # TODO + ] + models.RelatedFile.objects.bulk_create(db_related_files) + else: + models.Video.objects.create( + data=db_data, + path=os.path.relpath(video_path, upload_dir), + width=video_size[0], height=video_size[1] + ) + + # validate stop_frame + if db_data.stop_frame == 0: + db_data.stop_frame = db_data.start_frame + (db_data.size - 1) * db_data.get_frame_step() + else: + db_data.stop_frame = min(db_data.stop_frame, \ + db_data.start_frame + (db_data.size - 1) * db_data.get_frame_step()) + + slogger.glob.info("Found frames {} for Data #{}".format(db_data.size, db_data.id)) + _save_task_to_db(db_task, job_file_mapping=job_file_mapping) # TODO: split into jobs and task saving + + # Save chunks + # TODO: refactor + # TODO: save chunks per job if db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM or not settings.USE_CACHE: + def update_progress(progress): + # TODO: refactor this function into a class + progress_animation = '|/-\\' + if not hasattr(update_progress, 'call_counter'): + update_progress.call_counter = 0 + + status_message = 'CVAT is preparing data chunks' + if not progress: + status_message = '{} {}'.format(status_message, progress_animation[update_progress.call_counter]) + job.meta['status'] = status_message + job.meta['task_progress'] = progress or 0. + job.save_meta() + update_progress.call_counter = (update_progress.call_counter + 1) % len(progress_animation) + + counter = itertools.count() generator = itertools.groupby(extractor, lambda _: next(counter) // db_data.chunk_size) - generator = ((idx, list(chunk_data)) for idx, chunk_data in generator) + generator = ((chunk_idx, list(chunk_data)) for chunk_idx, chunk_data in generator) def save_chunks( - executor: concurrent.futures.ThreadPoolExecutor, - chunk_idx: int, - chunk_data: Iterable[tuple[str, str, str]]) -> list[tuple[str, int, tuple[int, int]]]: - nonlocal db_data, db_task, extractor, original_chunk_writer, compressed_chunk_writer - if (db_task.dimension == models.DimensionType.DIM_2D and + executor: concurrent.futures.ThreadPoolExecutor, + chunk_idx: int, + chunk_data: Iterable[tuple[str, str, str]] + ) -> list[tuple[str, int, tuple[int, int]]]: + if ( + db_task.dimension == models.DimensionType.DIM_2D and isinstance(extractor, ( MEDIA_TYPES['image']['extractor'], MEDIA_TYPES['zip']['extractor'], MEDIA_TYPES['pdf']['extractor'], MEDIA_TYPES['archive']['extractor'], - ))): + )) + ): chunk_data = preload_images(chunk_data) fs_original = executor.submit( @@ -1100,6 +1188,7 @@ def save_chunks( images=chunk_data, chunk_path=db_data.get_compressed_chunk_path(chunk_idx), ) + # TODO: convert to async for proper concurrency fs_original.result() image_sizes = fs_compressed.result() @@ -1107,58 +1196,17 @@ def save_chunks( return list((i[0][1], i[0][2], i[1]) for i in zip(chunk_data, image_sizes)) def process_results(img_meta: list[tuple[str, int, tuple[int, int]]]): - nonlocal db_images, db_data, video_path, video_size - - if db_task.mode == 'annotation': - db_images.extend( - models.Image( - data=db_data, - path=os.path.relpath(frame_path, upload_dir), - frame=frame_number, - width=frame_size[0], - height=frame_size[1]) - for frame_path, frame_number, frame_size in img_meta) - else: - video_size = img_meta[0][2] - video_path = img_meta[0][0] - - progress = extractor.get_progress(img_meta[-1][1]) + progress = img_meta[-1][1] / db_data.size update_progress(progress) futures = queue.Queue(maxsize=settings.CVAT_CONCURRENT_CHUNK_PROCESSING) - with concurrent.futures.ThreadPoolExecutor(max_workers=2*settings.CVAT_CONCURRENT_CHUNK_PROCESSING) as executor: + with concurrent.futures.ThreadPoolExecutor( + max_workers=2 * settings.CVAT_CONCURRENT_CHUNK_PROCESSING + ) as executor: for chunk_idx, chunk_data in generator: - db_data.size += len(chunk_data) if futures.full(): process_results(futures.get().result()) futures.put(executor.submit(save_chunks, executor, chunk_idx, chunk_data)) while not futures.empty(): process_results(futures.get().result()) - - if db_task.mode == 'annotation': - models.Image.objects.bulk_create(db_images) - created_images = models.Image.objects.filter(data_id=db_data.id) - - db_related_files = [ - models.RelatedFile(data=image.data, primary_image=image, path=os.path.join(upload_dir, related_file_path)) - for image in created_images - for related_file_path in related_images.get(image.path, []) - ] - models.RelatedFile.objects.bulk_create(db_related_files) - db_images = [] - else: - models.Video.objects.create( - data=db_data, - path=os.path.relpath(video_path, upload_dir), - width=video_size[0], height=video_size[1]) - - if db_data.stop_frame == 0: - db_data.stop_frame = db_data.start_frame + (db_data.size - 1) * db_data.get_frame_step() - else: - # validate stop_frame - db_data.stop_frame = min(db_data.stop_frame, \ - db_data.start_frame + (db_data.size - 1) * db_data.get_frame_step()) - - slogger.glob.info("Found frames {} for Data #{}".format(db_data.size, db_data.id)) - _save_task_to_db(db_task, job_file_mapping=job_file_mapping) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index a0edc4ae3403..13a7679595ec 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -54,7 +54,7 @@ from cvat.apps.events.handlers import handle_dataset_import from cvat.apps.dataset_manager.bindings import CvatImportError from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer -from cvat.apps.engine.frame_provider import FrameProvider +from cvat.apps.engine.frame_provider import FrameProvider, JobFrameProvider from cvat.apps.engine.filters import NonModelSimpleFilter, NonModelOrderingFilter, NonModelJsonLogicFilter from cvat.apps.engine.media_extractors import get_mime from cvat.apps.engine.models import ( @@ -625,7 +625,7 @@ def append_backup_chunk(self, request, file_id): def preview(self, request, pk): self._object = self.get_object() # call check_object_permissions as well - first_task = self._object.tasks.order_by('-id').first() + first_task: Optional[models.Task] = self._object.tasks.order_by('-id').first() if not first_task: return HttpResponseNotFound('Project image preview not found') @@ -746,13 +746,13 @@ def __init__(self, job: Job, data_type, data_num, data_quality): self.job = job def _check_frame_range(self, frame: int): - frame_range = self.job.segment.frame_set - if frame not in frame_range: + if frame not in self.job.segment.frame_set: raise ValidationError("The frame number doesn't belong to the job") def __call__(self, request, start, stop, db_data): + # TODO: add segment boundary handling if self.type == 'chunk' and self.job.segment.type == SegmentType.SPECIFIC_FRAMES: - frame_provider = FrameProvider(db_data, self.dimension) + frame_provider = JobFrameProvider(self.job) start_chunk = frame_provider.get_chunk_number(start) stop_chunk = frame_provider.get_chunk_number(stop) @@ -764,12 +764,10 @@ def __call__(self, request, start, stop, db_data): cache = MediaCache() if settings.USE_CACHE and db_data.storage_method == StorageMethodChoice.CACHE: - buf, mime = cache.get_selective_job_chunk_data_with_mime( - chunk_number=self.number, quality=self.quality, job=self.job - ) + buf, mime = cache.get_segment_chunk(self.job, self.number, quality=self.quality) else: - buf, mime = cache.prepare_selective_job_chunk( - chunk_number=self.number, quality=self.quality, db_job=self.job + buf, mime = cache.prepare_masked_range_segment_chunk( + self.job, self.number, quality=self.quality ) return HttpResponse(buf.getvalue(), content_type=mime) @@ -1298,8 +1296,7 @@ def data(self, request, pk): data_num = request.query_params.get('number', None) data_quality = request.query_params.get('quality', 'compressed') - data_getter = DataChunkGetter(data_type, data_num, data_quality, - self._object.dimension) + data_getter = DataChunkGetter(data_type, data_num, data_quality, self._object.dimension) return data_getter(request, self._object.data.start_frame, self._object.data.stop_frame, self._object.data) From d49233c60bb93d3f3fd34bd553b830f7ba18e96f Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 30 Jul 2024 19:18:57 +0300 Subject: [PATCH 010/227] t --- cvat/apps/dataset_manager/bindings.py | 16 +- cvat/apps/dataset_manager/formats/cvat.py | 23 +- cvat/apps/engine/cache.py | 128 +++++---- cvat/apps/engine/frame_provider.py | 313 +++++++++++++++++----- cvat/apps/engine/media_extractors.py | 104 +++---- cvat/apps/engine/models.py | 10 +- cvat/apps/engine/views.py | 189 ++++++------- cvat/apps/lambda_manager/views.py | 8 +- 8 files changed, 452 insertions(+), 339 deletions(-) diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index d94d6fd39e33..dfe8d458de6c 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -31,7 +31,7 @@ from cvat.apps.dataset_manager.formats.utils import get_label_color from cvat.apps.dataset_manager.util import add_prefetch_fields -from cvat.apps.engine.frame_provider import FrameProvider +from cvat.apps.engine.frame_provider import TaskFrameProvider, FrameQuality, FrameOutputType from cvat.apps.engine.models import (AttributeSpec, AttributeType, Data, DimensionType, Job, JobType, Label, LabelType, Project, SegmentType, ShapeType, Task) @@ -1348,8 +1348,8 @@ def video_frame_loader(_): # optimization for videos: use numpy arrays instead of bytes # some formats or transforms can require image data return self._frame_provider.get_frame(frame_index, - quality=FrameProvider.Quality.ORIGINAL, - out_type=FrameProvider.Type.NUMPY_ARRAY)[0] + quality=FrameQuality.ORIGINAL, + out_type=FrameOutputType.NUMPY_ARRAY).data return dm.Image(data=video_frame_loader, **image_kwargs) else: def image_loader(_): @@ -1357,8 +1357,8 @@ def image_loader(_): # for images use encoded data to avoid recoding return self._frame_provider.get_frame(frame_index, - quality=FrameProvider.Quality.ORIGINAL, - out_type=FrameProvider.Type.BUFFER)[0].getvalue() + quality=FrameQuality.ORIGINAL, + out_type=FrameOutputType.BUFFER).data.getvalue() return dm.ByteImage(data=image_loader, **image_kwargs) def _load_source(self, source_id: int, source: ImageSource) -> None: @@ -1366,7 +1366,7 @@ def _load_source(self, source_id: int, source: ImageSource) -> None: return self._unload_source() - self._frame_provider = FrameProvider(source.db_data) + self._frame_provider = TaskFrameProvider(next(iter(source.db_data.tasks))) # TODO: refactor self._current_source_id = source_id def _unload_source(self) -> None: @@ -1502,7 +1502,7 @@ def __init__( is_video = instance_meta['mode'] == 'interpolation' ext = '' if is_video: - ext = FrameProvider.VIDEO_FRAME_EXT + ext = TaskFrameProvider.VIDEO_FRAME_EXT if dimension == DimensionType.DIM_3D or include_images: self._image_provider = IMAGE_PROVIDERS_BY_DIMENSION[dimension]( @@ -1593,7 +1593,7 @@ def __init__( ) ext_per_task: Dict[int, str] = { - task.id: FrameProvider.VIDEO_FRAME_EXT if is_video else '' + task.id: TaskFrameProvider.VIDEO_FRAME_EXT if is_video else '' for task in project_data.tasks for is_video in [task.mode == 'interpolation'] } diff --git a/cvat/apps/dataset_manager/formats/cvat.py b/cvat/apps/dataset_manager/formats/cvat.py index 99293fe470d4..4d59682f6ee6 100644 --- a/cvat/apps/dataset_manager/formats/cvat.py +++ b/cvat/apps/dataset_manager/formats/cvat.py @@ -27,7 +27,7 @@ import_dm_annotations, match_dm_item) from cvat.apps.dataset_manager.util import make_zip_archive -from cvat.apps.engine.frame_provider import FrameProvider +from cvat.apps.engine.frame_provider import FrameQuality, FrameOutputType, make_frame_provider from .registry import dm_env, exporter, importer @@ -1371,16 +1371,19 @@ def dump_project_anno(dst_file: BufferedWriter, project_data: ProjectData, callb dumper.close_document() def dump_media_files(instance_data: CommonData, img_dir: str, project_data: ProjectData = None): + frame_provider = make_frame_provider(instance_data.db_instance) + ext = '' if instance_data.meta[instance_data.META_FIELD]['mode'] == 'interpolation': - ext = FrameProvider.VIDEO_FRAME_EXT - - frame_provider = FrameProvider(instance_data.db_data) - frames = frame_provider.get_frames( - instance_data.start, instance_data.stop, - frame_provider.Quality.ORIGINAL, - frame_provider.Type.BUFFER) - for frame_id, (frame_data, _) in zip(instance_data.rel_range, frames): + ext = frame_provider.VIDEO_FRAME_EXT + + frames = frame_provider.iterate_frames( + start_frame=instance_data.start, + stop_frame=instance_data.stop, + quality=FrameQuality.ORIGINAL, + out_type=FrameOutputType.BUFFER, + ) + for frame_id, frame in zip(instance_data.rel_range, frames): if (project_data is not None and (instance_data.db_instance.id, frame_id) in project_data.deleted_frames) \ or frame_id in instance_data.deleted_frames: continue @@ -1389,7 +1392,7 @@ def dump_media_files(instance_data: CommonData, img_dir: str, project_data: Proj img_path = osp.join(img_dir, frame_name + ext) os.makedirs(osp.dirname(img_path), exist_ok=True) with open(img_path, 'wb') as f: - f.write(frame_data.getvalue()) + f.write(frame.data.getvalue()) def _export_task_or_job(dst_file, temp_dir, instance_data, anno_callback, save_images=False): with open(osp.join(temp_dir, 'annotations.xml'), 'wb') as f: diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 988e76761214..521086296bb7 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -8,14 +8,15 @@ import io import os import pickle # nosec -import shutil import tempfile import zipfile import zlib -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from datetime import datetime, timezone -from typing import Any, Callable, Optional, Sequence, Tuple, Type +from itertools import pairwise +from typing import Any, Callable, Iterable, Optional, Sequence, Tuple, Type, Union +import av import cv2 import PIL.Image import PIL.ImageOps @@ -33,10 +34,10 @@ from cvat.apps.engine.media_extractors import ( FrameQuality, IChunkWriter, - ImageDatasetManifestReader, + ImageReaderWithManifest, Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter, - VideoDatasetManifestReader, + VideoReaderWithManifest, ZipChunkWriter, ZipCompressedChunkWriter, ) @@ -127,10 +128,10 @@ def get_selective_job_chunk( ), ) - def get_local_preview(self, db_data: models.Data, frame_number: int) -> DataWithMime: + def get_or_set_segment_preview(self, db_segment: models.Segment) -> DataWithMime: return self._get_or_set_cache_item( - key=f"data_{db_data.id}_{frame_number}_preview", - create_callback=lambda: self._prepare_local_preview(frame_number, db_data), + f"segment_preview_{db_segment.id}", + create_callback=lambda: self._prepare_segment_preview(db_segment), ) def get_cloud_preview(self, db_storage: models.CloudStorage) -> Optional[DataWithMime]: @@ -148,18 +149,17 @@ def get_frame_context_images(self, db_data: models.Data, frame_number: int) -> D create_callback=lambda: self._prepare_context_image(db_data, frame_number), ) - def get_task_preview(self, db_task: models.Task) -> Optional[DataWithMime]: - return self._get(f"task_{db_task.data_id}_preview") - - def get_segment_preview(self, db_segment: models.Segment) -> Optional[DataWithMime]: - return self._get(f"segment_{db_segment.id}_preview") - @contextmanager - def _read_raw_frames(self, db_task: models.Task, frames: Sequence[int]): + def _read_raw_frames( + self, db_task: models.Task, frame_ids: Sequence[int] + ) -> Iterable[Tuple[Union[av.VideoFrame, PIL.Image.Image], str, str]]: + for prev_frame, cur_frame in pairwise(frame_ids): + assert ( + prev_frame <= cur_frame + ), f"Requested frame ids must be sorted, got a ({prev_frame}, {cur_frame}) pair" + db_data = db_task.data - media = [] - tmp_dir = None raw_data_dir = { models.StorageChoice.LOCAL: db_data.get_upload_dirname(), models.StorageChoice.SHARE: settings.SHARE_ROOT, @@ -168,33 +168,20 @@ def _read_raw_frames(self, db_task: models.Task, frames: Sequence[int]): dimension = db_task.dimension - # TODO - try: + media = [] + with ExitStack() as es: if hasattr(db_data, "video"): source_path = os.path.join(raw_data_dir, db_data.video.path) - # TODO: refactor to allow non-manifest videos - reader = VideoDatasetManifestReader( + reader = VideoReaderWithManifest( manifest_path=db_data.get_manifest_path(), source_path=source_path, - chunk_number=chunk_number, - chunk_size=db_data.chunk_size, - start=db_data.start_frame, - stop=db_data.stop_frame, - step=db_data.get_frame_step(), ) - for frame in reader: + for frame in reader.iterate_frames(frame_ids): media.append((frame, source_path, None)) else: - reader = ImageDatasetManifestReader( - manifest_path=db_data.get_manifest_path(), - chunk_number=chunk_number, - chunk_size=db_data.chunk_size, - start=db_data.start_frame, - stop=db_data.stop_frame, - step=db_data.get_frame_step(), - ) - if db_data.storage == StorageChoice.CLOUD_STORAGE: + reader = ImageReaderWithManifest(db_data.get_manifest_path()) + if db_data.storage == models.StorageChoice.CLOUD_STORAGE: db_cloud_storage = db_data.cloud_storage assert db_cloud_storage, "Cloud storage instance was deleted" credentials = Credentials() @@ -213,10 +200,10 @@ def _read_raw_frames(self, db_task: models.Task, frames: Sequence[int]): cloud_provider=db_cloud_storage.provider_type, **details ) - tmp_dir = tempfile.mkdtemp(prefix="cvat") + tmp_dir = es.enter_context(tempfile.TemporaryDirectory(prefix="cvat")) files_to_download = [] checksums = [] - for item in reader: + for item in reader.iterate_frames(frame_ids): file_name = f"{item['name']}{item['extension']}" fs_filename = os.path.join(tmp_dir, file_name) @@ -235,18 +222,16 @@ def _read_raw_frames(self, db_task: models.Task, frames: Sequence[int]): "Hash sums of files {} do not match".format(file_name) ) else: - for item in reader: + for item in reader.iterate_frames(frame_ids): source_path = os.path.join( raw_data_dir, f"{item['name']}{item['extension']}" ) media.append((source_path, source_path, None)) + if dimension == models.DimensionType.DIM_2D: media = preload_images(media) yield media - finally: - if db_data.storage == models.StorageChoice.CLOUD_STORAGE and tmp_dir is not None: - shutil.rmtree(tmp_dir) def prepare_segment_chunk( self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality @@ -267,8 +252,8 @@ def prepare_range_segment_chunk( db_data = db_task.data chunk_size = db_data.chunk_size - chunk_frames = db_segment.frame_set[ - chunk_size * chunk_number : chunk_number * (chunk_number + 1) + chunk_frame_ids = db_segment.frame_set[ + chunk_size * chunk_number : chunk_size * (chunk_number + 1) ] writer_classes: dict[FrameQuality, Type[IChunkWriter]] = { @@ -300,26 +285,29 @@ def prepare_range_segment_chunk( kwargs["dimension"] = models.DimensionType.DIM_3D writer = writer_classes[quality](image_quality, **kwargs) - buff = io.BytesIO() - with self._read_raw_frames(db_task, frames=chunk_frames) as images: - writer.save_as_chunk(images, buff) + buffer = io.BytesIO() + with self._read_raw_frames(db_task, frame_ids=chunk_frame_ids) as images: + writer.save_as_chunk(images, buffer) - buff.seek(0) - return buff, mime_type + buffer.seek(0) + return buffer, mime_type def prepare_masked_range_segment_chunk( - self, db_job: models.Job, quality, chunk_number: int + self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality ) -> DataWithMime: - db_data = db_job.segment.task.data + # TODO: try to refactor into 1 function with prepare_range_segment_chunk() + db_task = db_segment.task + db_data = db_task.data - FrameProvider = self._get_frame_provider_class() - frame_provider = FrameProvider(db_data, self._dimension) + from cvat.apps.engine.frame_provider import TaskFrameProvider - frame_set = db_job.segment.frame_set + frame_provider = TaskFrameProvider(db_task) + + frame_set = db_segment.frame_set frame_step = db_data.get_frame_step() chunk_frames = [] - writer = ZipCompressedChunkWriter(db_data.image_quality, dimension=self._dimension) + writer = ZipCompressedChunkWriter(db_data.image_quality, dimension=db_task.dimension) dummy_frame = io.BytesIO() PIL.Image.new("RGB", (1, 1)).save(dummy_frame, writer.IMAGE_EXT) @@ -370,18 +358,23 @@ def prepare_masked_range_segment_chunk( return buff, "application/zip" - def prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: - if db_segment.task.data.cloud_storage: - return self._prepare_cloud_segment_preview(db_segment) + def _prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: + if db_segment.task.dimension == models.DimensionType.DIM_3D: + # TODO + preview = PIL.Image.open( + os.path.join(os.path.dirname(__file__), "assets/3d_preview.jpeg") + ) else: - return self._prepare_local_segment_preview(db_segment) + from cvat.apps.engine.frame_provider import FrameOutputType, SegmentFrameProvider - def _prepare_local_preview(self, db_data: models.Data, frame_number: int) -> DataWithMime: - FrameProvider = self._get_frame_provider_class() - frame_provider = FrameProvider(db_data, self._dimension) - buff, mime_type = frame_provider.get_preview(frame_number) + segment_frame_provider = SegmentFrameProvider(db_segment) + preview = segment_frame_provider.get_frame( + min(db_segment.frame_set), + quality=FrameQuality.COMPRESSED, + out_type=FrameOutputType.PIL, + ).data - return buff, mime_type + return prepare_preview_image(preview) def _prepare_cloud_preview(self, db_storage): storage = db_storage_to_storage_instance(db_storage) @@ -428,9 +421,10 @@ def prepare_context_images( except models.Image.DoesNotExist: return None + if not image.related_files.count(): + return None, None + with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file: - if not image.related_files.count(): - return None, None common_path = os.path.commonpath( list(map(lambda x: str(x.path), image.related_files.all())) ) @@ -457,4 +451,4 @@ def prepare_preview_image(image: PIL.Image.Image) -> DataWithMime: output_buf = io.BytesIO() image.convert("RGB").save(output_buf, format="JPEG") - return image, PREVIEW_MIME + return output_buf, PREVIEW_MIME diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 92354723b02e..77794d60ff76 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -5,8 +5,9 @@ from __future__ import annotations +import io import math -import os +from abc import ABCMeta, abstractmethod from dataclasses import dataclass from enum import Enum, auto from io import BytesIO @@ -19,8 +20,14 @@ from rest_framework.exceptions import ValidationError from cvat.apps.engine import models -from cvat.apps.engine.cache import DataWithMime, MediaCache, prepare_preview_image -from cvat.apps.engine.media_extractors import FrameQuality, IMediaReader, VideoReader, ZipReader +from cvat.apps.engine.cache import DataWithMime, MediaCache +from cvat.apps.engine.media_extractors import ( + FrameQuality, + IMediaReader, + VideoReader, + ZipCompressedChunkWriter, + ZipReader, +) from cvat.apps.engine.mime_types import mimetypes _T = TypeVar("_T") @@ -61,14 +68,11 @@ def close(self): self.pos = -1 -class _ChunkLoader: - def __init__( - self, reader_class: IMediaReader, path_getter: Callable[[int], DataWithMime] - ) -> None: +class _ChunkLoader(metaclass=ABCMeta): + def __init__(self, reader_class: IMediaReader) -> None: self.chunk_id: Optional[int] = None self.chunk_reader: Optional[_RandomAccessIterator] = None self.reader_class = reader_class - self.get_chunk_path = path_getter def load(self, chunk_id: int) -> _RandomAccessIterator[Tuple[Any, str, int]]: if self.chunk_id != chunk_id: @@ -76,7 +80,7 @@ def load(self, chunk_id: int) -> _RandomAccessIterator[Tuple[Any, str, int]]: self.chunk_id = chunk_id self.chunk_reader = _RandomAccessIterator( - self.reader_class([self.get_chunk_path(chunk_id)]) + self.reader_class([self.read_chunk(chunk_id)[0]]) ) return self.chunk_reader @@ -86,6 +90,36 @@ def unload(self): self.chunk_reader.close() self.chunk_reader = None + @abstractmethod + def read_chunk(self, chunk_id: int) -> DataWithMime: ... + + +class _FileChunkLoader(_ChunkLoader): + def __init__( + self, reader_class: IMediaReader, get_chunk_path_callback: Callable[[int], str] + ) -> None: + super().__init__(reader_class) + self.get_chunk_path = get_chunk_path_callback + + def read_chunk(self, chunk_id: int) -> DataWithMime: + chunk_path = self.get_chunk_path(chunk_id) + with open(chunk_path, "r") as f: + return ( + io.BytesIO(f.read()), + mimetypes.guess_type(chunk_path)[0], + ) + + +class _BufferChunkLoader(_ChunkLoader): + def __init__( + self, reader_class: IMediaReader, get_chunk_callback: Callable[[int], DataWithMime] + ) -> None: + super().__init__(reader_class) + self.get_chunk = get_chunk_callback + + def read_chunk(self, chunk_id: int) -> DataWithMime: + return self.get_chunk(chunk_id) + class FrameOutputType(Enum): BUFFER = auto() @@ -105,7 +139,7 @@ class DataWithMeta(Generic[_T]): checksum: int -class _FrameProvider: +class IFrameProvider(metaclass=ABCMeta): VIDEO_FRAME_EXT = ".PNG" VIDEO_FRAME_MIME = "image/png" @@ -118,7 +152,7 @@ def _av_frame_to_png_bytes(cls, av_frame: av.VideoFrame) -> BytesIO: image = av_frame.to_ndarray(format="bgr24") success, result = cv2.imencode(ext, image) if not success: - raise RuntimeError("Failed to encode image to '%s' format" % (ext)) + raise RuntimeError(f"Failed to encode image to '{ext}' format") return BytesIO(result.tobytes()) def _convert_frame( @@ -139,27 +173,145 @@ def _convert_frame( else: raise RuntimeError("unsupported output type") + @abstractmethod + def validate_frame_number(self, frame_number: int) -> int: ... + + @abstractmethod + def validate_chunk_number(self, chunk_number: int) -> int: ... + + @abstractmethod + def get_chunk_number(self, frame_number: int) -> int: ... + + @abstractmethod + def get_preview(self) -> DataWithMeta[BytesIO]: ... + + @abstractmethod + def get_chunk( + self, chunk_number: int, *, quality: FrameQuality = FrameQuality.ORIGINAL + ) -> DataWithMeta[BytesIO]: ... + + @abstractmethod + def get_frame( + self, + frame_number: int, + *, + quality: FrameQuality = FrameQuality.ORIGINAL, + out_type: FrameOutputType = FrameOutputType.BUFFER, + ) -> DataWithMeta[AnyFrame]: ... + + @abstractmethod + def get_frame_context_images( + self, + frame_number: int, + ) -> Optional[DataWithMeta[BytesIO]]: ... + + @abstractmethod + def iterate_frames( + self, + *, + start_frame: Optional[int] = None, + stop_frame: Optional[int] = None, + quality: FrameQuality = FrameQuality.ORIGINAL, + out_type: FrameOutputType = FrameOutputType.BUFFER, + ) -> Iterator[DataWithMeta[AnyFrame]]: ... + -class TaskFrameProvider(_FrameProvider): +class TaskFrameProvider(IFrameProvider): def __init__(self, db_task: models.Task) -> None: self._db_task = db_task - def _validate_frame_number(self, frame_number: int) -> int: - if not (0 <= frame_number < self._db_task.data.size): - raise ValidationError(f"Incorrect requested frame number: {frame_number}") + def validate_frame_number(self, frame_number: int) -> int: + start = self._db_task.data.start_frame + stop = self._db_task.data.stop_frame + if frame_number not in range(start, stop + 1, self._db_task.data.get_frame_step()): + raise ValidationError( + f"Invalid frame '{frame_number}'. " + f"The frame number should be in the [{start}, {stop}] range" + ) return frame_number + def validate_chunk_number(self, chunk_number: int) -> int: + start_chunk = 0 + stop_chunk = math.ceil(self._db_task.data.size / self._db_task.data.chunk_size) + if not (start_chunk <= chunk_number <= stop_chunk): + raise ValidationError( + f"Invalid chunk number '{chunk_number}'. " + f"The chunk number should be in the [{start_chunk}, {stop_chunk}] range" + ) + + return chunk_number + + def get_chunk_number(self, frame_number: int) -> int: + return int(frame_number) // self._db_task.data.chunk_size + def get_preview(self) -> DataWithMeta[BytesIO]: return self._get_segment_frame_provider(self._db_task.data.start_frame).get_preview() def get_chunk( self, chunk_number: int, *, quality: FrameQuality = FrameQuality.ORIGINAL ) -> DataWithMeta[BytesIO]: + return_type = DataWithMeta[BytesIO] + chunk_number = self.validate_chunk_number(chunk_number) + # TODO: return a joined chunk. Find a solution for segment boundary video chunks - return self._get_segment_frame_provider(frame_number).get_frame( - frame_number, quality=quality, out_type=out_type + db_data = self._db_task.data + step = db_data.get_frame_step() + task_chunk_start_frame = chunk_number * db_data.chunk_size + task_chunk_stop_frame = (chunk_number + 1) * db_data.chunk_size - 1 + task_chunk_frame_set = set( + range( + db_data.start_frame + task_chunk_start_frame * step, + min(db_data.start_frame + task_chunk_stop_frame * step, db_data.stop_frame) + step, + step, + ) + ) + + matching_segments = sorted( + [ + s + for s in self._db_task.segment_set + if s.type == models.SegmentType.RANGE + if not task_chunk_frame_set.isdisjoint(s.frame_set) + ], + key=lambda s: s.start_frame, + ) + assert matching_segments + + if len(matching_segments) == 1: + segment_frame_provider = SegmentFrameProvider(matching_segments[0]) + return segment_frame_provider.get_chunk( + segment_frame_provider.get_chunk_number(task_chunk_start_frame), quality=quality + ) + + 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 task_chunk_frame_set: + if task_chunk_frame_id not in segment_frame_set: + continue + + frame = segment_frame_provider.get_frame( + task_chunk_frame_id, quality=quality, out_type=FrameOutputType.BUFFER + ) + task_chunk_frames.append((frame, None, None)) + + merged_chunk_writer = ZipCompressedChunkWriter( + db_data.image_quality, dimension=self._db_task.dimension + ) + + buffer = io.BytesIO() + merged_chunk_writer.save_as_chunk( + task_chunk_frames, + buffer, + compress_frames=False, + zip_compress_level=1, ) + buffer.seek(0) + + return return_type(data=buffer, mime="application/zip", checksum=None) def get_frame( self, @@ -167,11 +319,17 @@ def get_frame( *, quality: FrameQuality = FrameQuality.ORIGINAL, out_type: FrameOutputType = FrameOutputType.BUFFER, - ) -> AnyFrame: + ) -> DataWithMeta[AnyFrame]: return self._get_segment_frame_provider(frame_number).get_frame( frame_number, quality=quality, out_type=out_type ) + def get_frame_context_images( + self, + frame_number: int, + ) -> Optional[DataWithMeta[BytesIO]]: + return self._get_segment_frame_provider(frame_number).get_frame_context_images(frame_number) + def iterate_frames( self, *, @@ -179,7 +337,7 @@ def iterate_frames( stop_frame: Optional[int] = None, quality: FrameQuality = FrameQuality.ORIGINAL, out_type: FrameOutputType = FrameOutputType.BUFFER, - ) -> Iterator[AnyFrame]: + ) -> Iterator[DataWithMeta[AnyFrame]]: # TODO: optimize segment access for idx in range(start_frame, (stop_frame + 1) if stop_frame else None): yield self.get_frame(idx, quality=quality, out_type=out_type) @@ -187,19 +345,16 @@ def iterate_frames( def _get_segment(self, validated_frame_number: int) -> models.Segment: return next( s - for s in self._db_task.segments.all() + for s in self._db_task.segment_set.all() if s.type == models.SegmentType.RANGE if validated_frame_number in s.frame_set ) - def _get_segment_frame_provider(self, frame_number: int) -> _SegmentFrameProvider: - segment = self._get_segment(self._validate_frame_number(frame_number)) - return _SegmentFrameProvider( - next(job for job in segment.jobs.all() if job.type == models.JobType.ANNOTATION) - ) + def _get_segment_frame_provider(self, frame_number: int) -> SegmentFrameProvider: + return SegmentFrameProvider(self._get_segment(self.validate_frame_number(frame_number))) -class _SegmentFrameProvider(_FrameProvider): +class SegmentFrameProvider(IFrameProvider): def __init__(self, db_segment: models.Segment) -> None: super().__init__() self._db_segment = db_segment @@ -215,26 +370,32 @@ def __init__(self, db_segment: models.Segment) -> None: if db_data.storage_method == models.StorageMethodChoice.CACHE: cache = MediaCache() - self._loaders[FrameQuality.COMPRESSED] = _ChunkLoader( - reader_class[db_data.compressed_chunk_type], - lambda chunk_idx: cache.get_segment_chunk( + self._loaders[FrameQuality.COMPRESSED] = _BufferChunkLoader( + reader_class=reader_class[db_data.compressed_chunk_type], + get_chunk_callback=lambda chunk_idx: cache.get_segment_chunk( db_segment, chunk_idx, quality=FrameQuality.COMPRESSED ), ) - self._loaders[FrameQuality.ORIGINAL] = _ChunkLoader( - reader_class[db_data.original_chunk_type], - lambda chunk_idx: cache.get_segment_chunk( + self._loaders[FrameQuality.ORIGINAL] = _BufferChunkLoader( + reader_class=reader_class[db_data.original_chunk_type], + get_chunk_callback=lambda chunk_idx: cache.get_segment_chunk( db_segment, chunk_idx, quality=FrameQuality.ORIGINAL ), ) else: - self._loaders[FrameQuality.COMPRESSED] = _ChunkLoader( - reader_class[db_data.compressed_chunk_type], db_data.get_compressed_chunk_path + self._loaders[FrameQuality.COMPRESSED] = _FileChunkLoader( + reader_class=reader_class[db_data.compressed_chunk_type], + get_chunk_path_callback=lambda chunk_idx: db_data.get_compressed_segment_chunk_path( + chunk_idx, segment=db_segment.id + ), ) - self._loaders[FrameQuality.ORIGINAL] = _ChunkLoader( - reader_class[db_data.original_chunk_type], db_data.get_original_chunk_path + self._loaders[FrameQuality.ORIGINAL] = _FileChunkLoader( + reader_class=reader_class[db_data.original_chunk_type], + get_chunk_path_callback=lambda chunk_idx: db_data.get_original_segment_chunk_path( + chunk_idx, segment=db_segment.id + ), ) def unload(self): @@ -244,9 +405,7 @@ def unload(self): def __len__(self): return self._db_segment.frame_count - def _validate_frame_number(self, frame_number: int) -> Tuple[int, int, int]: - # TODO: check for masked range segment - + def validate_frame_number(self, frame_number: int) -> Tuple[int, int, int]: if frame_number not in self._db_segment.frame_set: raise ValidationError(f"Incorrect requested frame number: {frame_number}") @@ -256,32 +415,29 @@ def _validate_frame_number(self, frame_number: int) -> Tuple[int, int, int]: def get_chunk_number(self, frame_number: int) -> int: return int(frame_number) // self._db_segment.task.data.chunk_size - def _validate_chunk_number(self, chunk_number: int) -> int: - segment_size = len(self._db_segment.frame_count) - if chunk_number < 0 or chunk_number >= math.ceil( - segment_size / self._db_segment.task.data.chunk_size - ): - raise ValidationError("requested chunk does not exist") + def validate_chunk_number(self, chunk_number: int) -> int: + segment_size = self._db_segment.frame_count + start_chunk = 0 + stop_chunk = math.ceil(segment_size / self._db_segment.task.data.chunk_size) + if not (start_chunk <= chunk_number <= stop_chunk): + raise ValidationError( + f"Invalid chunk number '{chunk_number}'. " + f"The chunk number should be in the [{start_chunk}, {stop_chunk}] range" + ) return chunk_number def get_preview(self) -> DataWithMeta[BytesIO]: - if self._db_segment.task.dimension == models.DimensionType.DIM_3D: - # TODO - preview = Image.open(os.path.join(os.path.dirname(__file__), "assets/3d_preview.jpeg")) - else: - preview, _ = self.get_frame( - min(self._db_segment.frame_set), - frame_number=FrameQuality.COMPRESSED, - out_type=FrameOutputType.PIL, - ) - - return prepare_preview_image(preview) + cache = MediaCache() + preview, mime = cache.get_or_set_segment_preview(self._db_segment) + return DataWithMeta[BytesIO](preview, mime=mime, checksum=None) def get_chunk( self, chunk_number: int, *, quality: FrameQuality = FrameQuality.ORIGINAL ) -> DataWithMeta[BytesIO]: - return self._loaders[quality].get_chunk_path(self._validate_chunk_number(chunk_number)) + chunk_number = self.validate_chunk_number(chunk_number) + chunk_data, mime = self._loaders[quality].read_chunk(chunk_number) + return DataWithMeta[BytesIO](chunk_data, mime=mime, checksum=None) def get_frame( self, @@ -289,16 +445,36 @@ def get_frame( *, quality: FrameQuality = FrameQuality.ORIGINAL, out_type: FrameOutputType = FrameOutputType.BUFFER, - ) -> AnyFrame: - _, chunk_number, frame_offset = self._validate_frame_number(frame_number) + ) -> DataWithMeta[AnyFrame]: + return_type = DataWithMeta[AnyFrame] + + _, chunk_number, frame_offset = self.validate_frame_number(frame_number) loader = self._loaders[quality] chunk_reader = loader.load(chunk_number) frame, frame_name, _ = chunk_reader[frame_offset] frame = self._convert_frame(frame, loader.reader_class, out_type) if loader.reader_class is VideoReader: - return (frame, self.VIDEO_FRAME_MIME) - return (frame, mimetypes.guess_type(frame_name)[0]) + return return_type(frame, mime=self.VIDEO_FRAME_MIME, checksum=None) + + return return_type(frame, mime=mimetypes.guess_type(frame_name)[0], checksum=None) + + def get_frame_context_images( + self, + frame_number: int, + ) -> Optional[DataWithMeta[BytesIO]]: + # TODO: refactor, optimize + cache = MediaCache() + + if self._db_segment.task.data.storage_method == models.StorageMethodChoice.CACHE: + data, mime = cache.get_frame_context_images(self._db_segment.task.data, frame_number) + else: + data, mime = cache.prepare_context_images(self._db_segment.task.data, frame_number) + + if not data: + return None + + return DataWithMeta[BytesIO](data, mime=mime, checksum=None) def iterate_frames( self, @@ -307,11 +483,22 @@ def iterate_frames( stop_frame: Optional[int] = None, quality: FrameQuality = FrameQuality.ORIGINAL, out_type: FrameOutputType = FrameOutputType.BUFFER, - ) -> Iterator[AnyFrame]: + ) -> Iterator[DataWithMeta[AnyFrame]]: for idx in range(start_frame, (stop_frame + 1) if stop_frame else None): yield self.get_frame(idx, quality=quality, out_type=out_type) -class JobFrameProvider(_SegmentFrameProvider): +class JobFrameProvider(SegmentFrameProvider): def __init__(self, db_job: models.Job) -> None: super().__init__(db_job.segment) + + +def make_frame_provider(data_source: Union[models.Job, models.Task, Any]) -> IFrameProvider: + if isinstance(data_source, models.Task): + frame_provider = TaskFrameProvider(data_source) + elif isinstance(data_source, models.Job): + frame_provider = JobFrameProvider(data_source) + else: + raise TypeError(f"Unexpected data source type {type(data_source)}") + + return frame_provider diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 7ca6ff0ed54d..7e67d45b6869 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -11,9 +11,10 @@ import io import itertools import struct -from enum import IntEnum from abc import ABC, abstractmethod +from bisect import bisect from contextlib import closing +from enum import IntEnum from typing import Iterable import av @@ -493,79 +494,42 @@ def get_image_size(self, i): image = (next(iter(self)))[0] return image.width, image.height -class FragmentMediaReader: - def __init__(self, chunk_number, chunk_size, start, stop, step=1): - self._start = start - self._stop = stop + 1 # up to the last inclusive - self._step = step - self._chunk_number = chunk_number - self._chunk_size = chunk_size - self._start_chunk_frame_number = \ - self._start + self._chunk_number * self._chunk_size * self._step - self._end_chunk_frame_number = min(self._start_chunk_frame_number \ - + (self._chunk_size - 1) * self._step + 1, self._stop) - self._frame_range = self._get_frame_range() - - @property - def frame_range(self): - return self._frame_range - - def _get_frame_range(self): - frame_range = [] - for idx in range(self._start, self._stop, self._step): - if idx < self._start_chunk_frame_number: - continue - elif idx < self._end_chunk_frame_number and \ - not (idx - self._start_chunk_frame_number) % self._step: - frame_range.append(idx) - elif (idx - self._start_chunk_frame_number) % self._step: - continue - else: - break - return frame_range - -class ImageDatasetManifestReader(FragmentMediaReader): - def __init__(self, manifest_path, **kwargs): - super().__init__(**kwargs) +class ImageReaderWithManifest: + def __init__(self, manifest_path: str): self._manifest = ImageManifestManager(manifest_path) self._manifest.init_index() - def __iter__(self): - for idx in self._frame_range: + def iterate_frames(self, frame_ids: Iterable[int]): + for idx in frame_ids: yield self._manifest[idx] -class VideoDatasetManifestReader(FragmentMediaReader): - def __init__(self, manifest_path, **kwargs): - self.source_path = kwargs.pop('source_path') - super().__init__(**kwargs) +class VideoReaderWithManifest: + def __init__(self, manifest_path: str, source_path: str): + self._source_path = source_path self._manifest = VideoManifestManager(manifest_path) self._manifest.init_index() - def _get_nearest_left_key_frame(self): - if self._start_chunk_frame_number >= \ - self._manifest[len(self._manifest) - 1].get('number'): - left_border = len(self._manifest) - 1 - else: - left_border = 0 - delta = len(self._manifest) - while delta: - step = delta // 2 - cur_position = left_border + step - if self._manifest[cur_position].get('number') < self._start_chunk_frame_number: - cur_position += 1 - left_border = cur_position - delta -= step + 1 - else: - delta = step - if self._manifest[cur_position].get('number') > self._start_chunk_frame_number: - left_border -= 1 - frame_number = self._manifest[left_border].get('number') - timestamp = self._manifest[left_border].get('pts') + def _get_nearest_left_key_frame(self, frame_id: int) -> tuple[int, int]: + nearest_left_keyframe_pos = bisect( + self._manifest, frame_id, key=lambda entry: entry.get('number') + ) + frame_number = self._manifest[nearest_left_keyframe_pos].get('number') + timestamp = self._manifest[nearest_left_keyframe_pos].get('pts') return frame_number, timestamp - def __iter__(self): - start_decode_frame_number, start_decode_timestamp = self._get_nearest_left_key_frame() - with closing(av.open(self.source_path, mode='r')) as container: + def iterate_frames(self, frame_ids: Iterable[int]) -> Iterable[av.VideoFrame]: + "frame_ids must be an ordered sequence in the ascending order" + + frame_ids_iter = iter(frame_ids) + frame_ids_frame = next(frame_ids_iter, None) + if frame_ids_frame is None: + return + + start_decode_frame_number, start_decode_timestamp = self._get_nearest_left_key_frame( + frame_ids_frame + ) + + with closing(av.open(self._source_path, mode='r')) as container: video_stream = next(stream for stream in container.streams if stream.type == 'video') video_stream.thread_type = 'AUTO' @@ -575,7 +539,10 @@ def __iter__(self): for packet in container.demux(video_stream): for frame in packet.decode(): frame_number += 1 - if frame_number in self._frame_range: + + if frame_number < frame_ids_frame: + continue + elif frame_number == frame_ids_frame: if video_stream.metadata.get('rotate'): frame = av.VideoFrame().from_ndarray( rotate_image( @@ -584,11 +551,12 @@ def __iter__(self): ), format ='bgr24' ) + yield frame - elif frame_number < self._frame_range[-1]: - continue else: - return + frame_ids_frame = next(frame_ids_iter, None) + if frame_ids_frame is None: + return class IChunkWriter(ABC): def __init__(self, quality, dimension=DimensionType.DIM_2D): diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index aab67ac13afb..8be47acecb97 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -272,12 +272,12 @@ def _get_compressed_chunk_name(self, chunk_number): def _get_original_chunk_name(self, chunk_number): return self._get_chunk_name(chunk_number, self.original_chunk_type) - def get_original_chunk_path(self, chunk_number): - return os.path.join(self.get_original_cache_dirname(), + def get_original_segment_chunk_path(self, chunk_number: int, segment: int) -> str: + return os.path.join(self.get_original_cache_dirname(), f'segment_{segment}', self._get_original_chunk_name(chunk_number)) - def get_compressed_chunk_path(self, chunk_number): - return os.path.join(self.get_compressed_cache_dirname(), + def get_compressed_segment_chunk_path(self, chunk_number: int, segment: int) -> str: + return os.path.join(self.get_compressed_cache_dirname(), f'segment_{segment}', self._get_compressed_chunk_name(chunk_number)) def get_manifest_path(self): @@ -558,7 +558,7 @@ def __str__(self): class Segment(models.Model): # Common fields - task = models.ForeignKey(Task, on_delete=models.CASCADE) + task = models.ForeignKey(Task, on_delete=models.CASCADE) # TODO: add related name start_frame = models.IntegerField() stop_frame = models.IntegerField() type = models.CharField(choices=SegmentType.choices(), default=SegmentType.RANGE, max_length=32) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 13a7679595ec..0fdaca30a744 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -3,12 +3,13 @@ # # SPDX-License-Identifier: MIT +from abc import ABCMeta, abstractmethod import os import os.path as osp import functools from PIL import Image from types import SimpleNamespace -from typing import Optional, Any, Dict, List, cast, Callable, Mapping, Iterable +from typing import Optional, Any, Dict, List, Union, cast, Callable, Mapping, Iterable import traceback import textwrap from collections import namedtuple @@ -54,7 +55,9 @@ from cvat.apps.events.handlers import handle_dataset_import from cvat.apps.dataset_manager.bindings import CvatImportError from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer -from cvat.apps.engine.frame_provider import FrameProvider, JobFrameProvider +from cvat.apps.engine.frame_provider import ( + IFrameProvider, TaskFrameProvider, JobFrameProvider, FrameQuality, FrameOutputType +) from cvat.apps.engine.filters import NonModelSimpleFilter, NonModelOrderingFilter, NonModelJsonLogicFilter from cvat.apps.engine.media_extractors import get_mime from cvat.apps.engine.models import ( @@ -629,15 +632,13 @@ def preview(self, request, pk): if not first_task: return HttpResponseNotFound('Project image preview not found') - data_getter = DataChunkGetter( + data_getter = _TaskDataGetter( + db_task=first_task, data_type='preview', data_quality='compressed', - data_num=first_task.data.start_frame, - task_dim=first_task.dimension ) - return data_getter(request, first_task.data.start_frame, - first_task.data.stop_frame, first_task.data) + return data_getter(request) @staticmethod def _get_rq_response(queue, job_id): @@ -657,80 +658,51 @@ def _get_rq_response(queue, job_id): return response -class DataChunkGetter: - def __init__(self, data_type, data_num, data_quality, task_dim): +class _DataGetter(metaclass=ABCMeta): + def __init__( + self, data_type: str, data_num: Optional[Union[str, int]], data_quality: str + ) -> None: possible_data_type_values = ('chunk', 'frame', 'preview', 'context_image') possible_quality_values = ('compressed', 'original') if not data_type or data_type not in possible_data_type_values: raise ValidationError('Data type not specified or has wrong value') elif data_type == 'chunk' or data_type == 'frame' or data_type == 'preview': - if data_num is None: + if data_num is None and data_type != 'preview': raise ValidationError('Number is not specified') elif data_quality not in possible_quality_values: raise ValidationError('Wrong quality value') self.type = data_type self.number = int(data_num) if data_num is not None else None - self.quality = FrameProvider.Quality.COMPRESSED \ - if data_quality == 'compressed' else FrameProvider.Quality.ORIGINAL - - self.dimension = task_dim + self.quality = FrameQuality.COMPRESSED \ + if data_quality == 'compressed' else FrameQuality.ORIGINAL - def _check_frame_range(self, frame: int): - frame_range = range(self._start, self._stop + 1, self._db_data.get_frame_step()) - if frame not in frame_range: - raise ValidationError( - f'The frame number should be in the [{self._start}, {self._stop}] range' - ) + @abstractmethod + def _get_frame_provider(self) -> IFrameProvider: + ... - def __call__(self, request, start: int, stop: int, db_data: Optional[Data]): - if not db_data: - raise NotFound(detail='Cannot find requested data') - - self._start = start - self._stop = stop - self._db_data = db_data - - frame_provider = FrameProvider(db_data, self.dimension) + def __call__(self): + frame_provider = self._get_frame_provider() try: if self.type == 'chunk': - start_chunk = frame_provider.get_chunk_number(start) - stop_chunk = frame_provider.get_chunk_number(stop) - # pylint: disable=superfluous-parens - if not (start_chunk <= self.number <= stop_chunk): - raise ValidationError('The chunk number should be in the ' + - f'[{start_chunk}, {stop_chunk}] range') - - # TODO: av.FFmpegError processing - if settings.USE_CACHE and db_data.storage_method == StorageMethodChoice.CACHE: - buff, mime_type = frame_provider.get_chunk(self.number, self.quality) - return HttpResponse(buff.getvalue(), content_type=mime_type) - - # Follow symbol links if the chunk is a link on a real image otherwise - # mimetype detection inside sendfile will work incorrectly. - path = os.path.realpath(frame_provider.get_chunk(self.number, self.quality)) - return sendfile(request, path) + data = frame_provider.get_chunk(self.number, quality=self.quality) + return HttpResponse(data.data.getvalue(), content_type=data.mime) # TODO: add new headers elif self.type == 'frame' or self.type == 'preview': - self._check_frame_range(self.number) - if self.type == 'preview': - cache = MediaCache(self.dimension) - buf, mime = cache.get_local_preview_with_mime(self.number, db_data) + data = frame_provider.get_preview() else: - buf, mime = frame_provider.get_frame(self.number, self.quality) + data = frame_provider.get_frame(self.number, quality=self.quality) - return HttpResponse(buf.getvalue(), content_type=mime) + return HttpResponse(data.data.getvalue(), content_type=data.mime) elif self.type == 'context_image': - self._check_frame_range(self.number) - - cache = MediaCache(self.dimension) - buff, mime = cache.get_frame_context_images(db_data, self.number) - if not buff: + data = frame_provider.get_frame_context_images(self.number) + if not data: return HttpResponseNotFound() - return HttpResponse(buff, content_type=mime) + + return HttpResponse(data.data, content_type=data.mime) else: return Response(data='unknown data type {}.'.format(self.type), status=status.HTTP_400_BAD_REQUEST) @@ -739,41 +711,36 @@ def __call__(self, request, start: int, stop: int, db_data: Optional[Data]): '\n'.join([str(d) for d in ex.detail]) return Response(data=msg, status=ex.status_code) - -class JobDataGetter(DataChunkGetter): - def __init__(self, job: Job, data_type, data_num, data_quality): - super().__init__(data_type, data_num, data_quality, task_dim=job.segment.task.dimension) - self.job = job - - def _check_frame_range(self, frame: int): - if frame not in self.job.segment.frame_set: - raise ValidationError("The frame number doesn't belong to the job") - - def __call__(self, request, start, stop, db_data): - # TODO: add segment boundary handling - if self.type == 'chunk' and self.job.segment.type == SegmentType.SPECIFIC_FRAMES: - frame_provider = JobFrameProvider(self.job) - - start_chunk = frame_provider.get_chunk_number(start) - stop_chunk = frame_provider.get_chunk_number(stop) - # pylint: disable=superfluous-parens - if not (start_chunk <= self.number <= stop_chunk): - raise ValidationError('The chunk number should be in the ' + - f'[{start_chunk}, {stop_chunk}] range') - - cache = MediaCache() - - if settings.USE_CACHE and db_data.storage_method == StorageMethodChoice.CACHE: - buf, mime = cache.get_segment_chunk(self.job, self.number, quality=self.quality) - else: - buf, mime = cache.prepare_masked_range_segment_chunk( - self.job, self.number, quality=self.quality - ) - - return HttpResponse(buf.getvalue(), content_type=mime) - - else: - return super().__call__(request, start, stop, db_data) +class _TaskDataGetter(_DataGetter): + def __init__( + self, + db_task: models.Task, + *, + data_type: str, + data_quality: str, + data_num: Optional[Union[str, int]] = None, + ) -> None: + super().__init__(data_type=data_type, data_num=data_num, data_quality=data_quality) + self._db_task = db_task + + def _get_frame_provider(self) -> IFrameProvider: + return TaskFrameProvider(self._db_task) + + +class _JobDataGetter(_DataGetter): + def __init__( + self, + db_job: models.Job, + *, + data_type: str, + data_quality: str, + data_num: Optional[Union[str, int]] = None, + ) -> None: + super().__init__(data_type=data_type, data_num=data_num, data_quality=data_quality) + self._db_job = db_job + + def _get_frame_provider(self) -> IFrameProvider: + return JobFrameProvider(self._db_job) @extend_schema(tags=['tasks']) @@ -1296,10 +1263,10 @@ def data(self, request, pk): data_num = request.query_params.get('number', None) data_quality = request.query_params.get('quality', 'compressed') - data_getter = DataChunkGetter(data_type, data_num, data_quality, self._object.dimension) - - return data_getter(request, self._object.data.start_frame, - self._object.data.stop_frame, self._object.data) + data_getter = _TaskDataGetter( + self._object, data_type=data_type, data_num=data_num, data_quality=data_quality + ) + return data_getter() @tus_chunk_action(detail=True, suffix_base="data") def append_data_chunk(self, request, pk, file_id): @@ -1640,15 +1607,12 @@ def preview(self, request, pk): if not self._object.data: return HttpResponseNotFound('Task image preview not found') - data_getter = DataChunkGetter( + data_getter = _TaskDataGetter( + db_task=self._object, data_type='preview', data_quality='compressed', - data_num=self._object.data.start_frame, - task_dim=self._object.dimension ) - - return data_getter(request, self._object.data.start_frame, - self._object.data.stop_frame, self._object.data) + return data_getter() @extend_schema(tags=['jobs']) @@ -2030,10 +1994,10 @@ def data(self, request, pk): data_num = request.query_params.get('number', None) data_quality = request.query_params.get('quality', 'compressed') - data_getter = JobDataGetter(db_job, data_type, data_num, data_quality) - - return data_getter(request, db_job.segment.start_frame, - db_job.segment.stop_frame, db_job.segment.task.data) + data_getter = _JobDataGetter( + db_job, data_type=data_type, data_num=data_num, data_quality=data_quality + ) + return data_getter() @extend_schema(methods=['GET'], summary='Get metainformation for media files in a job', @@ -2126,15 +2090,12 @@ def metadata(self, request, pk): def preview(self, request, pk): self._object = self.get_object() # call check_object_permissions as well - data_getter = DataChunkGetter( + data_getter = _JobDataGetter( + db_job=self._object, data_type='preview', data_quality='compressed', - data_num=self._object.segment.start_frame, - task_dim=self._object.segment.task.dimension ) - - return data_getter(request, self._object.segment.start_frame, - self._object.segment.stop_frame, self._object.segment.task.data) + return data_getter() @extend_schema(tags=['issues']) @@ -2704,12 +2665,12 @@ def preview(self, request, pk): # The idea is try to define real manifest preview only for the storages that have related manifests # because otherwise it can lead to extra calls to a bucket, that are usually not free. if not db_storage.has_at_least_one_manifest: - result = cache.get_cloud_preview_with_mime(db_storage) + result = cache.get_cloud_preview(db_storage) if not result: return HttpResponseNotFound('Cloud storage preview not found') return HttpResponse(result[0], result[1]) - preview, mime = cache.get_or_set_cloud_preview_with_mime(db_storage) + preview, mime = cache.get_or_set_cloud_preview(db_storage) return HttpResponse(preview, mime) except CloudStorageModel.DoesNotExist: message = f"Storage {pk} does not exist" diff --git a/cvat/apps/lambda_manager/views.py b/cvat/apps/lambda_manager/views.py index a6e37933f325..3e1f4691bba7 100644 --- a/cvat/apps/lambda_manager/views.py +++ b/cvat/apps/lambda_manager/views.py @@ -32,7 +32,7 @@ from rest_framework.request import Request import cvat.apps.dataset_manager as dm -from cvat.apps.engine.frame_provider import FrameProvider +from cvat.apps.engine.frame_provider import FrameQuality, TaskFrameProvider from cvat.apps.engine.models import Job, ShapeType, SourceType, Task, Label from cvat.apps.engine.serializers import LabeledDataSerializer from cvat.apps.lambda_manager.permissions import LambdaPermission @@ -480,16 +480,16 @@ def transform_attributes(input_attributes, attr_mapping, db_attributes): def _get_image(self, db_task, frame, quality): if quality is None or quality == "original": - quality = FrameProvider.Quality.ORIGINAL + quality = FrameQuality.ORIGINAL elif quality == "compressed": - quality = FrameProvider.Quality.COMPRESSED + quality = FrameQuality.COMPRESSED else: raise ValidationError( '`{}` lambda function was run '.format(self.id) + 'with wrong arguments (quality={})'.format(quality), code=status.HTTP_400_BAD_REQUEST) - frame_provider = FrameProvider(db_task.data) + frame_provider = TaskFrameProvider(db_task) image = frame_provider.get_frame(frame, quality=quality) return base64.b64encode(image[0].getvalue()).decode('utf-8') From 146a896f6d33a4153e98de95e75dc01a53b657c9 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 1 Aug 2024 19:37:47 +0300 Subject: [PATCH 011/227] Support static chunk building, fix av memory leak, add caching media iterator --- cvat/apps/engine/frame_provider.py | 98 +++++------ cvat/apps/engine/media_extractors.py | 248 +++++++++++++++++++++++---- cvat/apps/engine/models.py | 20 +-- cvat/apps/engine/task.py | 123 ++++++++----- 4 files changed, 350 insertions(+), 139 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 77794d60ff76..f47e3073203a 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from enum import Enum, auto from io import BytesIO -from typing import Any, Callable, Generic, Iterable, Iterator, Optional, Tuple, TypeVar, Union +from typing import Any, Callable, Generic, Iterator, Optional, Tuple, Type, TypeVar, Union import av import cv2 @@ -23,8 +23,13 @@ from cvat.apps.engine.cache import DataWithMime, MediaCache from cvat.apps.engine.media_extractors import ( FrameQuality, + IChunkWriter, IMediaReader, + Mpeg4ChunkWriter, + Mpeg4CompressedChunkWriter, + RandomAccessIterator, VideoReader, + ZipChunkWriter, ZipCompressedChunkWriter, ZipReader, ) @@ -33,53 +38,18 @@ _T = TypeVar("_T") -class _RandomAccessIterator(Iterator[_T]): - def __init__(self, iterable: Iterable[_T]): - self.iterable: Iterable[_T] = iterable - self.iterator: Optional[Iterator[_T]] = None - self.pos: int = -1 - - def __iter__(self): - return self - - def __next__(self): - return self[self.pos + 1] - - def __getitem__(self, idx: int) -> Optional[_T]: - assert 0 <= idx - if self.iterator is None or idx <= self.pos: - self.reset() - v = None - while self.pos < idx: - # NOTE: don't keep the last item in self, it can be expensive - v = next(self.iterator) - self.pos += 1 - return v - - def reset(self): - self.close() - self.iterator = iter(self.iterable) - - def close(self): - if self.iterator is not None: - if close := getattr(self.iterator, "close", None): - close() - self.iterator = None - self.pos = -1 - - class _ChunkLoader(metaclass=ABCMeta): def __init__(self, reader_class: IMediaReader) -> None: self.chunk_id: Optional[int] = None - self.chunk_reader: Optional[_RandomAccessIterator] = None + self.chunk_reader: Optional[RandomAccessIterator] = None self.reader_class = reader_class - def load(self, chunk_id: int) -> _RandomAccessIterator[Tuple[Any, str, int]]: + def load(self, chunk_id: int) -> RandomAccessIterator[Tuple[Any, str, int]]: if self.chunk_id != chunk_id: self.unload() self.chunk_id = chunk_id - self.chunk_reader = _RandomAccessIterator( + self.chunk_reader = RandomAccessIterator( self.reader_class([self.read_chunk(chunk_id)[0]]) ) return self.chunk_reader @@ -103,7 +73,7 @@ def __init__( def read_chunk(self, chunk_id: int) -> DataWithMime: chunk_path = self.get_chunk_path(chunk_id) - with open(chunk_path, "r") as f: + with open(chunk_path, "rb") as f: return ( io.BytesIO(f.read()), mimetypes.guess_type(chunk_path)[0], @@ -254,7 +224,6 @@ def get_chunk( return_type = DataWithMeta[BytesIO] chunk_number = self.validate_chunk_number(chunk_number) - # TODO: return a joined chunk. Find a solution for segment boundary video chunks db_data = self._db_task.data step = db_data.get_frame_step() task_chunk_start_frame = chunk_number * db_data.chunk_size @@ -270,7 +239,7 @@ def get_chunk( matching_segments = sorted( [ s - for s in self._db_task.segment_set + for s in self._db_task.segment_set.all() if s.type == models.SegmentType.RANGE if not task_chunk_frame_set.isdisjoint(s.frame_set) ], @@ -284,6 +253,8 @@ def get_chunk( segment_frame_provider.get_chunk_number(task_chunk_start_frame), quality=quality ) + # Create and return a joined chunk + # TODO: refactor into another class, optimize (don't visit frames twice) task_chunk_frames = [] for db_segment in matching_segments: segment_frame_provider = SegmentFrameProvider(db_segment) @@ -295,13 +266,38 @@ def get_chunk( frame = segment_frame_provider.get_frame( task_chunk_frame_id, quality=quality, out_type=FrameOutputType.BUFFER - ) + ).data task_chunk_frames.append((frame, None, None)) - merged_chunk_writer = ZipCompressedChunkWriter( - db_data.image_quality, dimension=self._db_task.dimension + writer_classes: dict[FrameQuality, Type[IChunkWriter]] = { + FrameQuality.COMPRESSED: ( + Mpeg4CompressedChunkWriter + if db_data.compressed_chunk_type == models.DataChoice.VIDEO + else ZipCompressedChunkWriter + ), + FrameQuality.ORIGINAL: ( + Mpeg4ChunkWriter + if db_data.original_chunk_type == models.DataChoice.VIDEO + else ZipChunkWriter + ), + } + + image_quality = ( + 100 + if writer_classes[quality] in [Mpeg4ChunkWriter, ZipChunkWriter] + else db_data.image_quality + ) + mime_type = ( + "video/mp4" + if writer_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] + else "application/zip" ) + kwargs = {} + if self._db_task.dimension == models.DimensionType.DIM_3D: + kwargs["dimension"] = models.DimensionType.DIM_3D + merged_chunk_writer = writer_classes[quality](image_quality, **kwargs) + buffer = io.BytesIO() merged_chunk_writer.save_as_chunk( task_chunk_frames, @@ -311,7 +307,9 @@ def get_chunk( ) buffer.seek(0) - return return_type(data=buffer, mime="application/zip", checksum=None) + # TODO: add caching + + return return_type(data=buffer, mime=mime_type, checksum=None) def get_frame( self, @@ -406,10 +404,14 @@ def __len__(self): return self._db_segment.frame_count def validate_frame_number(self, frame_number: int) -> Tuple[int, int, int]: - if frame_number not in self._db_segment.frame_set: + frame_sequence = list(self._db_segment.frame_set) + if frame_number not in frame_sequence: raise ValidationError(f"Incorrect requested frame number: {frame_number}") - chunk_number, frame_position = divmod(frame_number, self._db_segment.task.data.chunk_size) + # TODO: maybe optimize search + chunk_number, frame_position = divmod( + frame_sequence.index(frame_number), self._db_segment.task.data.chunk_size + ) return frame_number, chunk_number, frame_position def get_chunk_number(self, frame_number: int) -> int: diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 7e67d45b6869..80d8dfc1dba4 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: MIT +from __future__ import annotations + import os import sysconfig import tempfile @@ -13,11 +15,15 @@ import struct from abc import ABC, abstractmethod from bisect import bisect -from contextlib import closing +from contextlib import ExitStack, closing +from dataclasses import dataclass from enum import IntEnum -from typing import Iterable +from typing import Callable, Iterable, Iterator, Optional, Protocol, Tuple, TypeVar import av +import av.codec +import av.container +import av.video.stream import numpy as np from natsort import os_sorted from pyunpack import Archive @@ -92,6 +98,97 @@ def image_size_within_orientation(img: Image): def has_exif_rotation(img: Image): return img.getexif().get(ORIENTATION_EXIF_TAG, ORIENTATION.NORMAL_HORIZONTAL) != ORIENTATION.NORMAL_HORIZONTAL +_T = TypeVar("_T") + + +class RandomAccessIterator(Iterator[_T]): + def __init__(self, iterable: Iterable[_T]): + self.iterable: Iterable[_T] = iterable + self.iterator: Optional[Iterator[_T]] = None + self.pos: int = -1 + + def __iter__(self): + return self + + def __next__(self): + return self[self.pos + 1] + + def __getitem__(self, idx: int) -> Optional[_T]: + assert 0 <= idx + if self.iterator is None or idx <= self.pos: + self.reset() + v = None + while self.pos < idx: + # NOTE: don't keep the last item in self, it can be expensive + v = next(self.iterator) + self.pos += 1 + return v + + def reset(self): + self.close() + self.iterator = iter(self.iterable) + + def close(self): + if self.iterator is not None: + if close := getattr(self.iterator, "close", None): + close() + self.iterator = None + self.pos = -1 + + +class Sized(Protocol): + def get_size(self) -> int: ... + +_MediaT = TypeVar("_MediaT", bound=Sized) + +class CachingMediaIterator(RandomAccessIterator[_MediaT]): + @dataclass + class _CacheItem: + value: _MediaT + size: int + + def __init__( + self, + iterable: Iterable, + *, + max_cache_memory: int, + max_cache_entries: int, + object_size_callback: Optional[Callable[[_MediaT], int]] = None, + ): + super().__init__(iterable) + self.max_cache_entries = max_cache_entries + self.max_cache_memory = max_cache_memory + self._get_object_size_callback = object_size_callback + self.used_cache_memory = 0 + self._cache: dict[int, self._CacheItem] = {} + + def _get_object_size(self, obj: _MediaT) -> int: + if self._get_object_size_callback: + return self._get_object_size_callback(obj) + + return obj.get_size() + + def __getitem__(self, idx: int): + cache_item = self._cache.get(idx) + if cache_item: + return cache_item.value + + value = super().__getitem__(idx) + value_size = self._get_object_size(value) + + while ( + len(self._cache) + 1 > self.max_cache_entries or + self.used_cache_memory + value_size > self.max_cache_memory + ): + min_key = min(self._cache.keys()) + self._cache.pop(min_key) + + if self.used_cache_memory + value_size <= self.max_cache_memory: + self._cache[idx] = self._CacheItem(value, value_size) + + return value + + class IMediaReader(ABC): def __init__(self, source_path, step, start, stop, dimension): self._source_path = source_path @@ -409,7 +506,10 @@ def extract(self): os.remove(self._zip_source.filename) class VideoReader(IMediaReader): - def __init__(self, source_path, step=1, start=0, stop=None, dimension=DimensionType.DIM_2D): + def __init__( + self, source_path, step=1, start=0, stop=None, + dimension=DimensionType.DIM_2D, *, allow_threading: bool = True + ): super().__init__( source_path=source_path, step=step, @@ -418,6 +518,10 @@ def __init__(self, source_path, step=1, start=0, stop=None, dimension=DimensionT dimension=dimension, ) + self.allow_threading = allow_threading + self._frame_count: Optional[int] = None + self._frame_size: Optional[tuple[int, int]] = None # (w, h) + def _has_frame(self, i): if i >= self._start: if (i - self._start) % self._step == 0: @@ -426,26 +530,59 @@ def _has_frame(self, i): return False - def __iter__(self): - with self._get_av_container() as container: - stream = container.streams.video[0] - stream.thread_type = 'AUTO' + def _make_frame_iterator( + self, + *, + apply_filter: bool = True, + stream: Optional[av.video.stream.VideoStream] = None, + ) -> Iterator[Tuple[av.VideoFrame, str, int]]: + es = ExitStack() + + need_init = stream is None + if need_init: + container = es.enter_context(self._get_av_container()) + else: + container = stream.container + + with es: + if need_init: + stream = container.streams.video[0] + + if self.allow_threading: + stream.thread_type = 'AUTO' + + es.enter_context(closing(stream.codec_context)) + frame_num = 0 + for packet in container.demux(stream): for image in packet.decode(): frame_num += 1 - if self._has_frame(frame_num - 1): - if packet.stream.metadata.get('rotate'): - pts = image.pts - image = av.VideoFrame().from_ndarray( - rotate_image( - image.to_ndarray(format='bgr24'), - 360 - int(stream.metadata.get('rotate')) - ), - format ='bgr24' - ) - image.pts = pts - yield (image, self._source_path[0], image.pts) + + if apply_filter and not self._has_frame(frame_num - 1): + continue + + if stream.metadata.get('rotate'): + pts = image.pts + image = av.VideoFrame().from_ndarray( + rotate_image( + image.to_ndarray(format='bgr24'), + 360 - int(stream.metadata.get('rotate')) + ), + format ='bgr24' + ) + image.pts = pts + + if self._frame_size is None: + self._frame_size = (image.width, image.height) + + yield (image, self._source_path[0], image.pts) + + if self._frame_count is None: + self._frame_count = frame_num + + def __iter__(self): + return self._make_frame_iterator() def get_progress(self, pos): duration = self._get_duration() @@ -457,8 +594,11 @@ def _get_av_container(self): return av.open(self._source_path[0]) def _get_duration(self): - with self._get_av_container() as container: + with ExitStack() as es: + container = es.enter_context(self._get_av_container()) stream = container.streams.video[0] + es.enter_context(closing(stream.codec_context)) + duration = None if stream.duration: duration = stream.duration @@ -473,26 +613,52 @@ def _get_duration(self): return duration def get_preview(self, frame): - with self._get_av_container() as container: + with ExitStack() as es: + container = es.enter_context(self._get_av_container()) stream = container.streams.video[0] + es.enter_context(closing(stream.codec_context)) + tb_denominator = stream.time_base.denominator needed_time = int((frame / stream.guessed_rate) * tb_denominator) container.seek(offset=needed_time, stream=stream) - for packet in container.demux(stream): - for frame in packet.decode(): - return self._get_preview(frame.to_image() if not stream.metadata.get('rotate') \ - else av.VideoFrame().from_ndarray( - rotate_image( - frame.to_ndarray(format='bgr24'), - 360 - int(container.streams.video[0].metadata.get('rotate')) - ), - format ='bgr24' - ).to_image() - ) + + with closing(self._make_frame_iterator(stream=stream)) as frame_iter: + return self._get_preview(next(frame_iter)) def get_image_size(self, i): - image = (next(iter(self)))[0] - return image.width, image.height + if self._frame_size is not None: + return self._frame_size + + with closing(iter(self)) as frame_iter: + image = next(frame_iter)[0] + self._frame_size = (image.width, image.height) + + return self._frame_size + + def get_frame_count(self) -> int: + """ + Returns total frame count in the video + + Note that not all videos provide length / duration metainfo, so the + result may require full video decoding. + + The total count is NOT affected by the frame filtering options of the object, + i.e. start frame, end frame and frame step. + """ + # It's possible to retrieve frame count from the stream.frames, + # but the number may be incorrect. + # https://superuser.com/questions/1512575/why-total-frame-count-is-different-in-ffmpeg-than-ffprobe + if self._frame_count is not None: + return self._frame_count + + frame_count = 0 + for _ in self._make_frame_iterator(apply_filter=False): + frame_count += 1 + + self._frame_count = frame_count + + return frame_count + class ImageReaderWithManifest: def __init__(self, manifest_path: str): @@ -723,7 +889,7 @@ def __init__(self, quality=67): "preset": "ultrafast", } - def _add_video_stream(self, container, w, h, rate, options): + def _add_video_stream(self, container: av.container.OutputContainer, w, h, rate, options): # x264 requires width and height must be divisible by 2 for yuv420p if h % 2: h += 1 @@ -760,11 +926,15 @@ def save_as_chunk(self, images, chunk_path): options=self._codec_opts, ) - self._encode_images(images, output_container, output_v_stream) + with closing(output_v_stream): + self._encode_images(images, output_container, output_v_stream) + return [(input_w, input_h)] @staticmethod - def _encode_images(images, container, stream): + def _encode_images( + images, container: av.container.OutputContainer, stream: av.video.stream.VideoStream + ): for frame, _, _ in images: # let libav set the correct pts and time_base frame.pts = None @@ -812,7 +982,9 @@ def save_as_chunk(self, images, chunk_path): options=self._codec_opts, ) - self._encode_images(images, output_container, output_v_stream) + with closing(output_v_stream): + self._encode_images(images, output_container, output_v_stream) + return [(input_w, input_h)] def _is_archive(path): diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 8be47acecb97..ec0870a0e222 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -256,7 +256,7 @@ def get_original_cache_dirname(self): return os.path.join(self.get_data_dirname(), "original") @staticmethod - def _get_chunk_name(chunk_number, chunk_type): + def _get_chunk_name(segment_id: int, chunk_number: int, chunk_type: DataChoice | str) -> str: if chunk_type == DataChoice.VIDEO: ext = 'mp4' elif chunk_type == DataChoice.IMAGESET: @@ -264,21 +264,21 @@ def _get_chunk_name(chunk_number, chunk_type): else: ext = 'list' - return '{}.{}'.format(chunk_number, ext) + return 'segment_{}-{}.{}'.format(segment_id, chunk_number, ext) - def _get_compressed_chunk_name(self, chunk_number): - return self._get_chunk_name(chunk_number, self.compressed_chunk_type) + def _get_compressed_chunk_name(self, segment_id: int, chunk_number: int) -> str: + return self._get_chunk_name(segment_id, chunk_number, self.compressed_chunk_type) - def _get_original_chunk_name(self, chunk_number): - return self._get_chunk_name(chunk_number, self.original_chunk_type) + def _get_original_chunk_name(self, segment_id: int, chunk_number: int) -> str: + return self._get_chunk_name(segment_id, chunk_number, self.original_chunk_type) def get_original_segment_chunk_path(self, chunk_number: int, segment: int) -> str: - return os.path.join(self.get_original_cache_dirname(), f'segment_{segment}', - self._get_original_chunk_name(chunk_number)) + return os.path.join(self.get_original_cache_dirname(), + self._get_original_chunk_name(segment, chunk_number)) def get_compressed_segment_chunk_path(self, chunk_number: int, segment: int) -> str: - return os.path.join(self.get_compressed_cache_dirname(), f'segment_{segment}', - self._get_compressed_chunk_name(chunk_number)) + return os.path.join(self.get_compressed_cache_dirname(), + self._get_compressed_chunk_name(segment, chunk_number)) def get_manifest_path(self): return os.path.join(self.get_upload_dirname(), 'manifest.jsonl') diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 866e3d0c9133..274cb3c7206d 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -3,15 +3,17 @@ # # SPDX-License-Identifier: MIT +from contextlib import closing import itertools import fnmatch import os +import av import rq import re import shutil from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Sequence, Union, Iterable +from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Sequence, Tuple, Union, Iterable from urllib import parse as urlparse from urllib import request as urlrequest import concurrent.futures @@ -26,7 +28,8 @@ from cvat.apps.engine import models from cvat.apps.engine.log import ServerLogManager from cvat.apps.engine.media_extractors import ( - MEDIA_TYPES, IMediaReader, ImageListReader, Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter, + MEDIA_TYPES, CachingMediaIterator, IMediaReader, ImageListReader, + Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter, RandomAccessIterator, ValidateDimension, ZipChunkWriter, ZipCompressedChunkWriter, get_mime, sort ) from cvat.apps.engine.utils import ( @@ -119,7 +122,7 @@ def _copy_data_from_share_point( os.makedirs(target_dir) shutil.copyfile(source_path, target_path) -def _get_task_segment_data( +def _generate_segment_params( db_task: models.Task, *, data_size: Optional[int] = None, @@ -170,7 +173,7 @@ def _segments(): return SegmentsParams(segments, segment_size, overlap) -def _save_task_to_db( +def _create_segments_and_jobs( db_task: models.Task, *, job_file_mapping: Optional[JobFileMapping] = None, @@ -179,7 +182,7 @@ def _save_task_to_db( rq_job.meta['status'] = 'Task is being saved in database' rq_job.save_meta() - segments, segment_size, overlap = _get_task_segment_data( + segments, segment_size, overlap = _generate_segment_params( db_task=db_task, job_file_mapping=job_file_mapping, ) db_task.segment_size = segment_size @@ -975,7 +978,7 @@ def _update_status(msg: str) -> None: manifest_file = os.path.relpath(db_data.get_manifest_path(), upload_dir) video_path: str = "" - video_size: tuple[int, int] = (0, 0) + video_frame_size: tuple[int, int] = (0, 0) images: list[models.Image] = [] @@ -1000,8 +1003,8 @@ def _update_status(msg: str) -> None: if not len(manifest): raise ValidationError("No key frames found in the manifest") - all_frames = manifest.video_length - video_size = manifest.video_resolution + video_frame_count = manifest.video_length + video_frame_size = manifest.video_resolution manifest_is_prepared = True except Exception as ex: manifest.remove() @@ -1030,8 +1033,8 @@ def _update_status(msg: str) -> None: _update_status('A manifest has been created') - all_frames = len(manifest.reader) # TODO: check if the field access above and here are equivalent - video_size = manifest.reader.resolution + video_frame_count = len(manifest.reader) # TODO: check if the field access above and here are equivalent + video_frame_size = manifest.reader.resolution manifest_is_prepared = True except Exception as ex: manifest.remove() @@ -1044,14 +1047,14 @@ def _update_status(msg: str) -> None: _update_status("{} The task will be created using the old method".format(base_msg)) if not manifest: - all_frames = len(extractor) - video_size = extractor.get_image_size(0) + video_frame_count = extractor.get_frame_count() + video_frame_size = extractor.get_image_size(0) db_data.size = len(range( db_data.start_frame, min( - data['stop_frame'] + 1 if data['stop_frame'] else all_frames, - all_frames, + data['stop_frame'] + 1 if data['stop_frame'] else video_frame_count, + video_frame_count, ), db_data.get_frame_step() )) @@ -1126,7 +1129,7 @@ def _update_status(msg: str) -> None: models.Video.objects.create( data=db_data, path=os.path.relpath(video_path, upload_dir), - width=video_size[0], height=video_size[1] + width=video_frame_size[0], height=video_frame_size[1] ) # validate stop_frame @@ -1137,13 +1140,12 @@ def _update_status(msg: str) -> None: db_data.start_frame + (db_data.size - 1) * db_data.get_frame_step()) slogger.glob.info("Found frames {} for Data #{}".format(db_data.size, db_data.id)) - _save_task_to_db(db_task, job_file_mapping=job_file_mapping) # TODO: split into jobs and task saving + _create_segments_and_jobs(db_task, job_file_mapping=job_file_mapping) # Save chunks - # TODO: refactor - # TODO: save chunks per job + # TODO: refactor into a separate class / function for chunk creation if db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM or not settings.USE_CACHE: - def update_progress(progress): + def update_progress(progress: float): # TODO: refactor this function into a class progress_animation = '|/-\\' if not hasattr(update_progress, 'call_counter'): @@ -1157,16 +1159,48 @@ def update_progress(progress): job.save_meta() update_progress.call_counter = (update_progress.call_counter + 1) % len(progress_animation) + db_segments = db_task.segment_set.all() + + def generate_chunk_params() -> Iterator[Tuple[models.Segment, int, Sequence[Any]]]: + if isinstance(extractor, MEDIA_TYPES['video']['extractor']): + def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: + # There is no need to be absolutely precise here, + # just need to provide the reasonable upper boundary. + # Return bytes needed for 1 frame + frame = frame_tuple[0] + return frame.width * frame.height * (frame.format.padded_bits_per_pixel // 8) + + media_iterator = CachingMediaIterator( + extractor, + max_cache_memory=2 ** 30, max_cache_entries=db_task.overlap, + object_size_callback=_get_frame_size + ) + else: + media_iterator = RandomAccessIterator(extractor) + + with closing(media_iterator): + for db_segment in db_segments: + counter = itertools.count() + generator = ( + media_iterator[frame_idx] + for frame_idx in db_segment.frame_set # TODO: check absolute vs relative ids + ) # probably won't work for GT job segments with gaps + generator = itertools.groupby( + generator, lambda _: next(counter) // db_data.chunk_size + ) + generator = ( + (chunk_idx, list(chunk_data)) + for chunk_idx, chunk_data in generator + ) - counter = itertools.count() - generator = itertools.groupby(extractor, lambda _: next(counter) // db_data.chunk_size) - generator = ((chunk_idx, list(chunk_data)) for chunk_idx, chunk_data in generator) + yield (db_segment, generator) def save_chunks( executor: concurrent.futures.ThreadPoolExecutor, + db_segment: models.Segment, chunk_idx: int, - chunk_data: Iterable[tuple[str, str, str]] - ) -> list[tuple[str, int, tuple[int, int]]]: + chunk_data: Iterable[tuple[Any, str, str]] + ): if ( db_task.dimension == models.DimensionType.DIM_2D and isinstance(extractor, ( @@ -1181,32 +1215,35 @@ def save_chunks( fs_original = executor.submit( original_chunk_writer.save_as_chunk, images=chunk_data, - chunk_path=db_data.get_original_chunk_path(chunk_idx) + chunk_path=db_data.get_original_segment_chunk_path( + chunk_idx, segment=db_segment.id + ), ) - fs_compressed = executor.submit( - compressed_chunk_writer.save_as_chunk, + compressed_chunk_writer.save_as_chunk( images=chunk_data, - chunk_path=db_data.get_compressed_chunk_path(chunk_idx), + chunk_path=db_data.get_compressed_segment_chunk_path( + chunk_idx, segment=db_segment.id + ), ) - # TODO: convert to async for proper concurrency - fs_original.result() - image_sizes = fs_compressed.result() - - # (path, frame, size) - return list((i[0][1], i[0][2], i[1]) for i in zip(chunk_data, image_sizes)) - def process_results(img_meta: list[tuple[str, int, tuple[int, int]]]): - progress = img_meta[-1][1] / db_data.size - update_progress(progress) + fs_original.result() futures = queue.Queue(maxsize=settings.CVAT_CONCURRENT_CHUNK_PROCESSING) with concurrent.futures.ThreadPoolExecutor( - max_workers=2 * settings.CVAT_CONCURRENT_CHUNK_PROCESSING + max_workers=2 * settings.CVAT_CONCURRENT_CHUNK_PROCESSING # TODO: remove 2 * or configuration ) as executor: - for chunk_idx, chunk_data in generator: - if futures.full(): - process_results(futures.get().result()) - futures.put(executor.submit(save_chunks, executor, chunk_idx, chunk_data)) + # TODO: maybe make real multithreading support, currently the code is limited by 1 + # segment chunk, even if more threads are available + for segment_idx, (segment, segment_chunk_params) in enumerate(generate_chunk_params()): + for chunk_idx, chunk_data in segment_chunk_params: + if futures.full(): + futures.get().result() + + futures.put(executor.submit( + save_chunks, executor, segment, chunk_idx, chunk_data + )) + + update_progress(segment_idx / len(db_segments)) while not futures.empty(): - process_results(futures.get().result()) + futures.get().result() From 52d1bacce9d258595b6caaa499426c7ac3ea9bed Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 2 Aug 2024 17:26:13 +0300 Subject: [PATCH 012/227] Refactor static chunk generation - extract function, revise threading --- cvat/apps/engine/task.py | 244 +++++++++++++++++++++------------------ 1 file changed, 131 insertions(+), 113 deletions(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 274cb3c7206d..b6c9a0aae38b 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -3,22 +3,22 @@ # # SPDX-License-Identifier: MIT -from contextlib import closing +import concurrent.futures import itertools import fnmatch import os -import av -import rq import re +import rq import shutil +from contextlib import closing from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Sequence, Tuple, Union, Iterable from urllib import parse as urlparse from urllib import request as urlrequest -import concurrent.futures -import queue +import av +import attrs import django_rq from django.conf import settings from django.db import transaction @@ -190,8 +190,8 @@ def _create_segments_and_jobs( for segment_idx, segment_params in enumerate(segments): slogger.glob.info( - "New segment for task #{task_id}: idx = {segment_idx}, start_frame = {start_frame}, \ - stop_frame = {stop_frame}".format( + "New segment for task #{task_id}: idx = {segment_idx}, start_frame = {start_frame}, " + "stop_frame = {stop_frame}".format( task_id=db_task.id, segment_idx=segment_idx, **segment_params._asdict() )) @@ -936,24 +936,10 @@ def _update_status(msg: str) -> None: db_data.original_chunk_type = models.DataChoice.VIDEO if task_mode == 'interpolation' else models.DataChoice.IMAGESET compressed_chunk_writer_class = Mpeg4CompressedChunkWriter if db_data.compressed_chunk_type == models.DataChoice.VIDEO else ZipCompressedChunkWriter - if db_data.original_chunk_type == models.DataChoice.VIDEO: - original_chunk_writer_class = Mpeg4ChunkWriter - # Let's use QP=17 (that is 67 for 0-100 range) for the original chunks, which should be visually lossless or nearly so. - # A lower value will significantly increase the chunk size with a slight increase of quality. - original_quality = 67 - else: - original_chunk_writer_class = ZipChunkWriter - original_quality = 100 - - kwargs = {} - if validate_dimension.dimension == models.DimensionType.DIM_3D: - kwargs["dimension"] = validate_dimension.dimension - compressed_chunk_writer = compressed_chunk_writer_class(db_data.image_quality, **kwargs) - original_chunk_writer = original_chunk_writer_class(original_quality, **kwargs) # calculate chunk size if it isn't specified if db_data.chunk_size is None: - if isinstance(compressed_chunk_writer, ZipCompressedChunkWriter): + if issubclass(compressed_chunk_writer_class, ZipCompressedChunkWriter): first_image_idx = db_data.start_frame if not is_data_in_cloud: w, h = extractor.get_image_size(first_image_idx) @@ -1027,7 +1013,7 @@ def _update_status(msg: str) -> None: manifest.link( media_file=media_files[0], upload_dir=upload_dir, - chunk_size=db_data.chunk_size + chunk_size=db_data.chunk_size # TODO: why it's needed here? ) manifest.create() @@ -1142,108 +1128,140 @@ def _update_status(msg: str) -> None: slogger.glob.info("Found frames {} for Data #{}".format(db_data.size, db_data.id)) _create_segments_and_jobs(db_task, job_file_mapping=job_file_mapping) - # Save chunks - # TODO: refactor into a separate class / function for chunk creation if db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM or not settings.USE_CACHE: - def update_progress(progress: float): - # TODO: refactor this function into a class + _create_static_chunks(db_task, media_extractor=extractor) + +def _create_static_chunks(db_task: models.Task, *, media_extractor: IMediaReader): + @attrs.define + class _ChunkProgressUpdater: + _call_counter: int = attrs.field(default=0, init=False) + _rq_job: rq.job.Job = attrs.field(factory=rq.get_current_job) + + def update_progress(self, progress: float): progress_animation = '|/-\\' - if not hasattr(update_progress, 'call_counter'): - update_progress.call_counter = 0 status_message = 'CVAT is preparing data chunks' if not progress: - status_message = '{} {}'.format(status_message, progress_animation[update_progress.call_counter]) - job.meta['status'] = status_message - job.meta['task_progress'] = progress or 0. - job.save_meta() - update_progress.call_counter = (update_progress.call_counter + 1) % len(progress_animation) - - db_segments = db_task.segment_set.all() - - def generate_chunk_params() -> Iterator[Tuple[models.Segment, int, Sequence[Any]]]: - if isinstance(extractor, MEDIA_TYPES['video']['extractor']): - def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: - # There is no need to be absolutely precise here, - # just need to provide the reasonable upper boundary. - # Return bytes needed for 1 frame - frame = frame_tuple[0] - return frame.width * frame.height * (frame.format.padded_bits_per_pixel // 8) - - media_iterator = CachingMediaIterator( - extractor, - max_cache_memory=2 ** 30, max_cache_entries=db_task.overlap, - object_size_callback=_get_frame_size + status_message = '{} {}'.format( + status_message, progress_animation[self._call_counter] ) - else: - media_iterator = RandomAccessIterator(extractor) - - with closing(media_iterator): - for db_segment in db_segments: - counter = itertools.count() - generator = ( - media_iterator[frame_idx] - for frame_idx in db_segment.frame_set # TODO: check absolute vs relative ids - ) # probably won't work for GT job segments with gaps - generator = itertools.groupby( - generator, lambda _: next(counter) // db_data.chunk_size - ) - generator = ( - (chunk_idx, list(chunk_data)) - for chunk_idx, chunk_data in generator - ) - yield (db_segment, generator) + self._rq_job.meta['status'] = status_message + self._rq_job.meta['task_progress'] = progress or 0. + self._rq_job.save_meta() + + self._call_counter = (self._call_counter + 1) % len(progress_animation) - def save_chunks( - executor: concurrent.futures.ThreadPoolExecutor, - db_segment: models.Segment, - chunk_idx: int, - chunk_data: Iterable[tuple[Any, str, str]] + def save_chunks( + executor: concurrent.futures.ThreadPoolExecutor, + db_segment: models.Segment, + chunk_idx: int, + chunk_frame_ids: Sequence[int] + ): + chunk_data = [media_iterator[frame_idx] for frame_idx in chunk_frame_ids] + + if ( + db_task.dimension == models.DimensionType.DIM_2D and + isinstance(media_extractor, ( + MEDIA_TYPES['image']['extractor'], + MEDIA_TYPES['zip']['extractor'], + MEDIA_TYPES['pdf']['extractor'], + MEDIA_TYPES['archive']['extractor'], + )) ): - if ( - db_task.dimension == models.DimensionType.DIM_2D and - isinstance(extractor, ( - MEDIA_TYPES['image']['extractor'], - MEDIA_TYPES['zip']['extractor'], - MEDIA_TYPES['pdf']['extractor'], - MEDIA_TYPES['archive']['extractor'], - )) - ): - chunk_data = preload_images(chunk_data) + chunk_data = preload_images(chunk_data) - fs_original = executor.submit( - original_chunk_writer.save_as_chunk, - images=chunk_data, - chunk_path=db_data.get_original_segment_chunk_path( - chunk_idx, segment=db_segment.id - ), - ) - compressed_chunk_writer.save_as_chunk( - images=chunk_data, - chunk_path=db_data.get_compressed_segment_chunk_path( - chunk_idx, segment=db_segment.id - ), - ) + # TODO: extract into a class - fs_original.result() + fs_original = executor.submit( + original_chunk_writer.save_as_chunk, + images=chunk_data, + chunk_path=db_data.get_original_segment_chunk_path( + chunk_idx, segment=db_segment.id + ), + ) + compressed_chunk_writer.save_as_chunk( + images=chunk_data, + chunk_path=db_data.get_compressed_segment_chunk_path( + chunk_idx, segment=db_segment.id + ), + ) + + fs_original.result() - futures = queue.Queue(maxsize=settings.CVAT_CONCURRENT_CHUNK_PROCESSING) - with concurrent.futures.ThreadPoolExecutor( - max_workers=2 * settings.CVAT_CONCURRENT_CHUNK_PROCESSING # TODO: remove 2 * or configuration - ) as executor: - # TODO: maybe make real multithreading support, currently the code is limited by 1 - # segment chunk, even if more threads are available - for segment_idx, (segment, segment_chunk_params) in enumerate(generate_chunk_params()): - for chunk_idx, chunk_data in segment_chunk_params: - if futures.full(): - futures.get().result() + db_data = db_task.data - futures.put(executor.submit( - save_chunks, executor, segment, chunk_idx, chunk_data - )) + if db_data.compressed_chunk_type == models.DataChoice.VIDEO: + compressed_chunk_writer_class = Mpeg4CompressedChunkWriter + else: + compressed_chunk_writer_class = ZipCompressedChunkWriter - update_progress(segment_idx / len(db_segments)) + if db_data.original_chunk_type == models.DataChoice.VIDEO: + original_chunk_writer_class = Mpeg4ChunkWriter + + # Let's use QP=17 (that is 67 for 0-100 range) for the original chunks, + # which should be visually lossless or nearly so. + # A lower value will significantly increase the chunk size with a slight increase of quality. + original_quality = 67 + else: + original_chunk_writer_class = ZipChunkWriter + original_quality = 100 + + chunk_writer_kwargs = {} + if db_task.dimension == models.DimensionType.DIM_3D: + chunk_writer_kwargs["dimension"] = db_task.dimension + compressed_chunk_writer = compressed_chunk_writer_class( + db_data.image_quality, **chunk_writer_kwargs + ) + original_chunk_writer = original_chunk_writer_class(original_quality, **chunk_writer_kwargs) + + db_segments = db_task.segment_set.all() + + if isinstance(media_extractor, MEDIA_TYPES['video']['extractor']): + def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: + # There is no need to be absolutely precise here, + # just need to provide the reasonable upper boundary. + # Return bytes needed for 1 frame + frame = frame_tuple[0] + return frame.width * frame.height * (frame.format.padded_bits_per_pixel // 8) + + # Currently, we only optimize video creation for sequential + # chunks with potential overlap, so parallel processing is likely to + # help only for image datasets + media_iterator = CachingMediaIterator( + media_extractor, + max_cache_memory=2 ** 30, max_cache_entries=db_task.overlap, + object_size_callback=_get_frame_size + ) + else: + media_iterator = RandomAccessIterator(media_extractor) + + with closing(media_iterator): + progress_updater = _ChunkProgressUpdater() + + # TODO: remove 2 * or the configuration option + # TODO: maybe make real multithreading support, currently the code is limited by 1 + # video segment chunk, even if more threads are available + max_concurrency = 2 * settings.CVAT_CONCURRENT_CHUNK_PROCESSING if not isinstance( + media_extractor, MEDIA_TYPES['video']['extractor'] + ) else 2 + with concurrent.futures.ThreadPoolExecutor(max_workers=max_concurrency) as executor: + frame_step = db_data.get_frame_step() + for segment_idx, db_segment in enumerate(db_segments): + frame_counter = itertools.count() + for chunk_idx, chunk_frame_ids in ( + (chunk_idx, list(chunk_frame_ids)) + for chunk_idx, chunk_frame_ids in itertools.groupby( + ( + # Convert absolute to relative ids (extractor output positions) + # Extractor will skip frames outside requested + (abs_frame_id - db_data.start_frame) // frame_step + for abs_frame_id in db_segment.frame_set + # TODO: is start frame different for video and images? + ), + lambda _: next(frame_counter) // db_data.chunk_size + ) + ): + save_chunks(executor, db_segment, chunk_idx, chunk_frame_ids) - while not futures.empty(): - futures.get().result() + progress_updater.update_progress(segment_idx / len(db_segments)) From 0c5343683898d2025d213160f0afc180a6dd2fa6 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 2 Aug 2024 19:07:39 +0300 Subject: [PATCH 013/227] Refactor and fix task chunk creation from segment chunks, any storage --- cvat/apps/engine/frame_provider.py | 58 ++++++++++-------------------- cvat/apps/engine/task.py | 1 - 2 files changed, 19 insertions(+), 40 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index f47e3073203a..0c7c5d4b49f7 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -204,7 +204,7 @@ def validate_frame_number(self, frame_number: int) -> int: def validate_chunk_number(self, chunk_number: int) -> int: start_chunk = 0 stop_chunk = math.ceil(self._db_task.data.size / self._db_task.data.chunk_size) - if not (start_chunk <= chunk_number <= stop_chunk): + if not (start_chunk <= chunk_number < stop_chunk): raise ValidationError( f"Invalid chunk number '{chunk_number}'. " f"The chunk number should be in the [{start_chunk}, {stop_chunk}] range" @@ -247,69 +247,49 @@ def get_chunk( ) assert matching_segments - if len(matching_segments) == 1: + if len(matching_segments) == 1 and task_chunk_frame_set == set( + matching_segments[0].frame_set + ): segment_frame_provider = SegmentFrameProvider(matching_segments[0]) return segment_frame_provider.get_chunk( segment_frame_provider.get_chunk_number(task_chunk_start_frame), quality=quality ) - # Create and return a joined chunk - # TODO: refactor into another class, optimize (don't visit frames twice) - task_chunk_frames = [] + # Create and return a joined / cleaned chunk + # TODO: refactor into another class, maybe optimize + 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 task_chunk_frame_set: - if task_chunk_frame_id not in 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 = segment_frame_provider.get_frame( task_chunk_frame_id, quality=quality, out_type=FrameOutputType.BUFFER ).data - task_chunk_frames.append((frame, None, None)) - - writer_classes: dict[FrameQuality, Type[IChunkWriter]] = { - FrameQuality.COMPRESSED: ( - Mpeg4CompressedChunkWriter - if db_data.compressed_chunk_type == models.DataChoice.VIDEO - else ZipCompressedChunkWriter - ), - FrameQuality.ORIGINAL: ( - Mpeg4ChunkWriter - if db_data.original_chunk_type == models.DataChoice.VIDEO - else ZipChunkWriter - ), - } - - image_quality = ( - 100 - if writer_classes[quality] in [Mpeg4ChunkWriter, ZipChunkWriter] - else db_data.image_quality - ) - mime_type = ( - "video/mp4" - if writer_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] - else "application/zip" - ) + task_chunk_frames[task_chunk_frame_id] = (frame, None, None) kwargs = {} if self._db_task.dimension == models.DimensionType.DIM_3D: kwargs["dimension"] = models.DimensionType.DIM_3D - merged_chunk_writer = writer_classes[quality](image_quality, **kwargs) + merged_chunk_writer = ZipCompressedChunkWriter( + 100 if quality == FrameQuality.ORIGINAL else db_data.image_quality, **kwargs + ) buffer = io.BytesIO() merged_chunk_writer.save_as_chunk( - task_chunk_frames, - buffer, - compress_frames=False, - zip_compress_level=1, + task_chunk_frames.values(), buffer, compress_frames=False, zip_compress_level=1 ) buffer.seek(0) - # TODO: add caching + # TODO: add caching in media cache for the resulting chunk - return return_type(data=buffer, mime=mime_type, checksum=None) + return return_type(data=buffer, mime="application/zip", checksum=None) def get_frame( self, diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index b6c9a0aae38b..0416704b9136 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1257,7 +1257,6 @@ def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: # Extractor will skip frames outside requested (abs_frame_id - db_data.start_frame) // frame_step for abs_frame_id in db_segment.frame_set - # TODO: is start frame different for video and images? ), lambda _: next(frame_counter) // db_data.chunk_size ) From c1661237b1eea5b79393e823a33dbb136eef57ee Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 5 Aug 2024 16:19:02 +0300 Subject: [PATCH 014/227] Fix chunk number validation --- cvat/apps/engine/frame_provider.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 0c7c5d4b49f7..0da5c5f1f862 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -202,12 +202,11 @@ def validate_frame_number(self, frame_number: int) -> int: return frame_number def validate_chunk_number(self, chunk_number: int) -> int: - start_chunk = 0 - stop_chunk = math.ceil(self._db_task.data.size / self._db_task.data.chunk_size) - if not (start_chunk <= chunk_number < stop_chunk): + last_chunk = math.ceil(self._db_task.data.size / self._db_task.data.chunk_size) - 1 + if not (0 <= chunk_number <= last_chunk): raise ValidationError( f"Invalid chunk number '{chunk_number}'. " - f"The chunk number should be in the [{start_chunk}, {stop_chunk}] range" + f"The chunk number should be in the [0, {last_chunk}] range" ) return chunk_number @@ -399,12 +398,11 @@ def get_chunk_number(self, frame_number: int) -> int: def validate_chunk_number(self, chunk_number: int) -> int: segment_size = self._db_segment.frame_count - start_chunk = 0 - stop_chunk = math.ceil(segment_size / self._db_segment.task.data.chunk_size) - if not (start_chunk <= chunk_number <= stop_chunk): + last_chunk = math.ceil(segment_size / self._db_segment.task.data.chunk_size) - 1 + if not (0 <= chunk_number <= last_chunk): raise ValidationError( f"Invalid chunk number '{chunk_number}'. " - f"The chunk number should be in the [{start_chunk}, {stop_chunk}] range" + f"The chunk number should be in the [0, {last_chunk}] range" ) return chunk_number From 630c97edc78f2f7a5def7fb187c7183863c05f91 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 5 Aug 2024 16:26:15 +0300 Subject: [PATCH 015/227] Enable formatting for updated components --- dev/format_python_code.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dev/format_python_code.sh b/dev/format_python_code.sh index a67bf08572e7..10469178f902 100755 --- a/dev/format_python_code.sh +++ b/dev/format_python_code.sh @@ -23,6 +23,8 @@ for paths in \ "tests/python/" \ "cvat/apps/quality_control" \ "cvat/apps/analytics_report" \ + "cvat/apps/engine/frame_provider.py" \ + "cvat/apps/engine/cache.py" \ ; do ${BLACK} -- ${paths} ${ISORT} -- ${paths} From 8d710e701013ec2b0542d601175cf88bc06f1ea1 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 5 Aug 2024 16:49:00 +0300 Subject: [PATCH 016/227] Remove the checksum field --- cvat/apps/engine/frame_provider.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 0da5c5f1f862..d3235781b491 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -106,7 +106,6 @@ class FrameOutputType(Enum): class DataWithMeta(Generic[_T]): data: _T mime: str - checksum: int class IFrameProvider(metaclass=ABCMeta): @@ -288,7 +287,7 @@ def get_chunk( # TODO: add caching in media cache for the resulting chunk - return return_type(data=buffer, mime="application/zip", checksum=None) + return return_type(data=buffer, mime="application/zip") def get_frame( self, @@ -410,14 +409,14 @@ def validate_chunk_number(self, chunk_number: int) -> int: def get_preview(self) -> DataWithMeta[BytesIO]: cache = MediaCache() preview, mime = cache.get_or_set_segment_preview(self._db_segment) - return DataWithMeta[BytesIO](preview, mime=mime, checksum=None) + return DataWithMeta[BytesIO](preview, mime=mime) def get_chunk( self, chunk_number: int, *, quality: FrameQuality = FrameQuality.ORIGINAL ) -> DataWithMeta[BytesIO]: chunk_number = self.validate_chunk_number(chunk_number) chunk_data, mime = self._loaders[quality].read_chunk(chunk_number) - return DataWithMeta[BytesIO](chunk_data, mime=mime, checksum=None) + return DataWithMeta[BytesIO](chunk_data, mime=mime) def get_frame( self, @@ -435,9 +434,9 @@ def get_frame( frame = self._convert_frame(frame, loader.reader_class, out_type) if loader.reader_class is VideoReader: - return return_type(frame, mime=self.VIDEO_FRAME_MIME, checksum=None) + return return_type(frame, mime=self.VIDEO_FRAME_MIME) - return return_type(frame, mime=mimetypes.guess_type(frame_name)[0], checksum=None) + return return_type(frame, mime=mimetypes.guess_type(frame_name)[0]) def get_frame_context_images( self, @@ -454,7 +453,7 @@ def get_frame_context_images( if not data: return None - return DataWithMeta[BytesIO](data, mime=mime, checksum=None) + return DataWithMeta[BytesIO](data, mime=mime) def iterate_frames( self, From 654a827ce56f6debe51a875d40e5d7d0079c140c Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 6 Aug 2024 12:19:07 +0300 Subject: [PATCH 017/227] Be consistent about returned task chunk types (allow video chunks) --- cvat/apps/engine/frame_provider.py | 75 ++++++++++++++++++++-------- cvat/apps/engine/media_extractors.py | 6 ++- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index d3235781b491..a299ba29a0d7 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -125,14 +125,14 @@ def _av_frame_to_png_bytes(cls, av_frame: av.VideoFrame) -> BytesIO: return BytesIO(result.tobytes()) def _convert_frame( - self, frame: Any, reader_class: IMediaReader, out_type: FrameOutputType + self, frame: Any, reader: IMediaReader, out_type: FrameOutputType ) -> AnyFrame: if out_type == FrameOutputType.BUFFER: - return self._av_frame_to_png_bytes(frame) if reader_class is VideoReader else frame + return self._av_frame_to_png_bytes(frame) if isinstance(reader, VideoReader) else frame elif out_type == FrameOutputType.PIL: - return frame.to_image() if reader_class is VideoReader else Image.open(frame) + return frame.to_image() if isinstance(reader, VideoReader) else Image.open(frame) elif out_type == FrameOutputType.NUMPY_ARRAY: - if reader_class is VideoReader: + if isinstance(reader, VideoReader): image = frame.to_ndarray(format="bgr24") else: image = np.array(Image.open(frame)) @@ -255,6 +255,28 @@ def get_chunk( # Create and return a joined / cleaned chunk # TODO: refactor into another class, maybe optimize + + writer_classes: dict[FrameQuality, Type[IChunkWriter]] = { + FrameQuality.COMPRESSED: ( + Mpeg4CompressedChunkWriter + if db_data.compressed_chunk_type == models.DataChoice.VIDEO + else ZipCompressedChunkWriter + ), + FrameQuality.ORIGINAL: ( + Mpeg4ChunkWriter + if db_data.original_chunk_type == models.DataChoice.VIDEO + else ZipChunkWriter + ), + } + + writer_class = writer_classes[quality] + + mime_type = ( + "video/mp4" + if writer_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] + else "application/zip" + ) + task_chunk_frames = {} for db_segment in matching_segments: segment_frame_provider = SegmentFrameProvider(db_segment) @@ -267,27 +289,27 @@ def get_chunk( ): continue - frame = segment_frame_provider.get_frame( - task_chunk_frame_id, quality=quality, out_type=FrameOutputType.BUFFER - ).data + frame, _, _ = segment_frame_provider._get_raw_frame( + task_chunk_frame_id, quality=quality + ) task_chunk_frames[task_chunk_frame_id] = (frame, None, None) - kwargs = {} + writer_kwargs = {} if self._db_task.dimension == models.DimensionType.DIM_3D: - kwargs["dimension"] = models.DimensionType.DIM_3D - merged_chunk_writer = ZipCompressedChunkWriter( - 100 if quality == FrameQuality.ORIGINAL else db_data.image_quality, **kwargs - ) + writer_kwargs["dimension"] = models.DimensionType.DIM_3D + merged_chunk_writer = writer_class(int(quality), **writer_kwargs) + + writer_kwargs = {} + if isinstance(merged_chunk_writer, ZipCompressedChunkWriter): + writer_kwargs = dict(compress_frames=False, zip_compress_level=1) buffer = io.BytesIO() - merged_chunk_writer.save_as_chunk( - task_chunk_frames.values(), buffer, compress_frames=False, zip_compress_level=1 - ) + merged_chunk_writer.save_as_chunk(list(task_chunk_frames.values()), buffer, **writer_kwargs) buffer.seek(0) # TODO: add caching in media cache for the resulting chunk - return return_type(data=buffer, mime="application/zip") + return return_type(data=buffer, mime=mime_type) def get_frame( self, @@ -418,6 +440,18 @@ def get_chunk( chunk_data, mime = self._loaders[quality].read_chunk(chunk_number) return DataWithMeta[BytesIO](chunk_data, mime=mime) + def _get_raw_frame( + self, + frame_number: int, + *, + quality: FrameQuality = FrameQuality.ORIGINAL, + ) -> Tuple[Any, str, IMediaReader]: + _, chunk_number, frame_offset = self.validate_frame_number(frame_number) + loader = self._loaders[quality] + chunk_reader = loader.load(chunk_number) + frame, frame_name, _ = chunk_reader[frame_offset] + return frame, frame_name, chunk_reader + def get_frame( self, frame_number: int, @@ -427,13 +461,10 @@ def get_frame( ) -> DataWithMeta[AnyFrame]: return_type = DataWithMeta[AnyFrame] - _, chunk_number, frame_offset = self.validate_frame_number(frame_number) - loader = self._loaders[quality] - chunk_reader = loader.load(chunk_number) - frame, frame_name, _ = chunk_reader[frame_offset] + frame, frame_name, reader = self._get_raw_frame(frame_number, quality=quality) - frame = self._convert_frame(frame, loader.reader_class, out_type) - if loader.reader_class is VideoReader: + frame = self._convert_frame(frame, reader, out_type) + if isinstance(reader, VideoReader): return return_type(frame, mime=self.VIDEO_FRAME_MIME) return return_type(frame, mime=mimetypes.guess_type(frame_name)[0]) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 80d8dfc1dba4..883de77c96aa 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -18,7 +18,7 @@ from contextlib import ExitStack, closing from dataclasses import dataclass from enum import IntEnum -from typing import Callable, Iterable, Iterator, Optional, Protocol, Tuple, TypeVar +from typing import Callable, Iterable, Iterator, Optional, Protocol, Sequence, Tuple, TypeVar import av import av.codec @@ -910,7 +910,9 @@ def _add_video_stream(self, container: av.container.OutputContainer, w, h, rate, return video_stream - def save_as_chunk(self, images, chunk_path): + def save_as_chunk( + self, images: Sequence[av.VideoFrame], chunk_path: str + ) -> Sequence[Tuple[int, int]]: if not images: raise Exception('no images to save') From 12e5f2ac7dfc46b059810ffa38116b4b7b5f8661 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 6 Aug 2024 13:01:02 +0300 Subject: [PATCH 018/227] Support iterator input in video chunk writing --- cvat/apps/engine/frame_provider.py | 2 +- cvat/apps/engine/media_extractors.py | 31 +++++++++++++++++++++------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index a299ba29a0d7..2ad8854d520f 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -304,7 +304,7 @@ def get_chunk( writer_kwargs = dict(compress_frames=False, zip_compress_level=1) buffer = io.BytesIO() - merged_chunk_writer.save_as_chunk(list(task_chunk_frames.values()), buffer, **writer_kwargs) + merged_chunk_writer.save_as_chunk(task_chunk_frames.values(), buffer, **writer_kwargs) buffer.seek(0) # TODO: add caching in media cache for the resulting chunk diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 883de77c96aa..33e247a5a7c7 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -18,7 +18,7 @@ from contextlib import ExitStack, closing from dataclasses import dataclass from enum import IntEnum -from typing import Callable, Iterable, Iterator, Optional, Protocol, Sequence, Tuple, TypeVar +from typing import Any, Callable, Iterable, Iterator, Optional, Protocol, Sequence, Tuple, TypeVar import av import av.codec @@ -910,14 +910,28 @@ def _add_video_stream(self, container: av.container.OutputContainer, w, h, rate, return video_stream + FrameDescriptor = Tuple[av.VideoFrame, Any, Any] + + def _peek_first_frame( + self, frame_iter: Iterator[FrameDescriptor] + ) -> Tuple[Optional[FrameDescriptor], Iterator[FrameDescriptor]]: + "Gets the first frame and returns the same full iterator" + + if not hasattr(frame_iter, '__next__'): + frame_iter = iter(frame_iter) + + first_frame = next(frame_iter, None) + return first_frame, itertools.chain((first_frame, ), frame_iter) + def save_as_chunk( - self, images: Sequence[av.VideoFrame], chunk_path: str + self, images: Iterator[FrameDescriptor], chunk_path: str ) -> Sequence[Tuple[int, int]]: - if not images: + first_frame, images = self._peek_first_frame(images) + if not first_frame: raise Exception('no images to save') - input_w = images[0][0].width - input_h = images[0][0].height + input_w = first_frame[0].width + input_h = first_frame[0].height with av.open(chunk_path, 'w', format=self.FORMAT) as output_container: output_v_stream = self._add_video_stream( @@ -962,11 +976,12 @@ def __init__(self, quality): } def save_as_chunk(self, images, chunk_path): - if not images: + first_frame, images = self._peek_first_frame(images) + if not first_frame: raise Exception('no images to save') - input_w = images[0][0].width - input_h = images[0][0].height + input_w = first_frame[0].width + input_h = first_frame[0].height downscale_factor = 1 while input_h / downscale_factor >= 1080: From a79a681c8d369a0670496b61c053f7a2279f1e04 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 6 Aug 2024 13:05:29 +0300 Subject: [PATCH 019/227] Fix type annotation --- cvat/apps/engine/frame_provider.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 2ad8854d520f..e9b655aa5c29 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -39,7 +39,7 @@ class _ChunkLoader(metaclass=ABCMeta): - def __init__(self, reader_class: IMediaReader) -> None: + def __init__(self, reader_class: Type[IMediaReader]) -> None: self.chunk_id: Optional[int] = None self.chunk_reader: Optional[RandomAccessIterator] = None self.reader_class = reader_class @@ -66,7 +66,7 @@ def read_chunk(self, chunk_id: int) -> DataWithMime: ... class _FileChunkLoader(_ChunkLoader): def __init__( - self, reader_class: IMediaReader, get_chunk_path_callback: Callable[[int], str] + self, reader_class: Type[IMediaReader], get_chunk_path_callback: Callable[[int], str] ) -> None: super().__init__(reader_class) self.get_chunk_path = get_chunk_path_callback @@ -82,7 +82,7 @@ def read_chunk(self, chunk_id: int) -> DataWithMime: class _BufferChunkLoader(_ChunkLoader): def __init__( - self, reader_class: IMediaReader, get_chunk_callback: Callable[[int], DataWithMime] + self, reader_class: Type[IMediaReader], get_chunk_callback: Callable[[int], DataWithMime] ) -> None: super().__init__(reader_class) self.get_chunk = get_chunk_callback @@ -359,7 +359,7 @@ def __init__(self, db_segment: models.Segment) -> None: db_data = db_segment.task.data - reader_class: dict[models.DataChoice, IMediaReader] = { + reader_class: dict[models.DataChoice, Type[IMediaReader]] = { models.DataChoice.IMAGESET: ZipReader, models.DataChoice.VIDEO: VideoReader, } From d5118a2d253e5eb9388bfcc9f98286c083c8d61c Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 6 Aug 2024 13:43:03 +0300 Subject: [PATCH 020/227] Refactor video reader memory leak fix, add to reader with manifest --- cvat/apps/engine/media_extractors.py | 69 +++++++++++++++++++--------- 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 33e247a5a7c7..c7314bcf4b6a 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -15,7 +15,7 @@ import struct from abc import ABC, abstractmethod from bisect import bisect -from contextlib import ExitStack, closing +from contextlib import ExitStack, closing, contextmanager from dataclasses import dataclass from enum import IntEnum from typing import Any, Callable, Iterable, Iterator, Optional, Protocol, Sequence, Tuple, TypeVar @@ -538,21 +538,19 @@ def _make_frame_iterator( ) -> Iterator[Tuple[av.VideoFrame, str, int]]: es = ExitStack() - need_init = stream is None - if need_init: - container = es.enter_context(self._get_av_container()) + needs_init = stream is None + if needs_init: + container = es.enter_context(self._read_av_container()) else: container = stream.container with es: - if need_init: + if needs_init: stream = container.streams.video[0] if self.allow_threading: stream.thread_type = 'AUTO' - es.enter_context(closing(stream.codec_context)) - frame_num = 0 for packet in container.demux(stream): @@ -588,16 +586,27 @@ def get_progress(self, pos): duration = self._get_duration() return pos / duration if duration else None - def _get_av_container(self): + @contextmanager + def _read_av_container(self): if isinstance(self._source_path[0], io.BytesIO): self._source_path[0].seek(0) # required for re-reading - return av.open(self._source_path[0]) + + container = av.open(self._source_path[0]) + try: + yield container + finally: + # fixes a memory leak in input container closing + # https://github.com/PyAV-Org/PyAV/issues/1117 + for stream in container.streams: + context = stream.codec_context + if context and context.is_open: + context.close() + + container.close() def _get_duration(self): - with ExitStack() as es: - container = es.enter_context(self._get_av_container()) + with self._read_av_container() as container: stream = container.streams.video[0] - es.enter_context(closing(stream.codec_context)) duration = None if stream.duration: @@ -613,10 +622,8 @@ def _get_duration(self): return duration def get_preview(self, frame): - with ExitStack() as es: - container = es.enter_context(self._get_av_container()) + with self._read_av_container() as container: stream = container.streams.video[0] - es.enter_context(closing(stream.codec_context)) tb_denominator = stream.time_base.denominator needed_time = int((frame / stream.guessed_rate) * tb_denominator) @@ -670,11 +677,28 @@ def iterate_frames(self, frame_ids: Iterable[int]): yield self._manifest[idx] class VideoReaderWithManifest: - def __init__(self, manifest_path: str, source_path: str): + def __init__(self, manifest_path: str, source_path: str, *, allow_threading: bool = False): self._source_path = source_path self._manifest = VideoManifestManager(manifest_path) self._manifest.init_index() + self.allow_threading = allow_threading + + @contextmanager + def _read_av_container(self): + container = av.open(self._source_path[0]) + try: + yield container + finally: + # fixes a memory leak in input container closing + # https://github.com/PyAV-Org/PyAV/issues/1117 + for stream in container.streams: + context = stream.codec_context + if context and context.is_open: + context.close() + + container.close() + def _get_nearest_left_key_frame(self, frame_id: int) -> tuple[int, int]: nearest_left_keyframe_pos = bisect( self._manifest, frame_id, key=lambda entry: entry.get('number') @@ -695,21 +719,22 @@ def iterate_frames(self, frame_ids: Iterable[int]) -> Iterable[av.VideoFrame]: frame_ids_frame ) - with closing(av.open(self._source_path, mode='r')) as container: - video_stream = next(stream for stream in container.streams if stream.type == 'video') - video_stream.thread_type = 'AUTO' + with self._read_av_container() as container: + stream = container.streams.video[0] + if self.allow_threading: + stream.thread_type = 'AUTO' - container.seek(offset=start_decode_timestamp, stream=video_stream) + container.seek(offset=start_decode_timestamp, stream=stream) frame_number = start_decode_frame_number - 1 - for packet in container.demux(video_stream): + for packet in container.demux(stream): for frame in packet.decode(): frame_number += 1 if frame_number < frame_ids_frame: continue elif frame_number == frame_ids_frame: - if video_stream.metadata.get('rotate'): + if stream.metadata.get('rotate'): frame = av.VideoFrame().from_ndarray( rotate_image( frame.to_ndarray(format='bgr24'), From 1b429cffb4e4a9649cfc78b777426098567cadc4 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 6 Aug 2024 13:43:34 +0300 Subject: [PATCH 021/227] Disable threading in video reading in frame provider --- cvat/apps/engine/frame_provider.py | 52 +++++++++++++++++++++------- cvat/apps/engine/media_extractors.py | 2 +- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index e9b655aa5c29..e06be42d64ab 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -39,10 +39,16 @@ class _ChunkLoader(metaclass=ABCMeta): - def __init__(self, reader_class: Type[IMediaReader]) -> None: + def __init__( + self, + reader_class: Type[IMediaReader], + *, + reader_params: Optional[dict] = None, + ) -> None: self.chunk_id: Optional[int] = None self.chunk_reader: Optional[RandomAccessIterator] = None self.reader_class = reader_class + self.reader_params = reader_params def load(self, chunk_id: int) -> RandomAccessIterator[Tuple[Any, str, int]]: if self.chunk_id != chunk_id: @@ -50,7 +56,10 @@ def load(self, chunk_id: int) -> RandomAccessIterator[Tuple[Any, str, int]]: self.chunk_id = chunk_id self.chunk_reader = RandomAccessIterator( - self.reader_class([self.read_chunk(chunk_id)[0]]) + self.reader_class( + [self.read_chunk(chunk_id)[0]], + **(self.reader_params or {}), + ) ) return self.chunk_reader @@ -66,9 +75,13 @@ def read_chunk(self, chunk_id: int) -> DataWithMime: ... class _FileChunkLoader(_ChunkLoader): def __init__( - self, reader_class: Type[IMediaReader], get_chunk_path_callback: Callable[[int], str] + self, + reader_class: Type[IMediaReader], + get_chunk_path_callback: Callable[[int], str], + *, + reader_params: Optional[dict] = None, ) -> None: - super().__init__(reader_class) + super().__init__(reader_class, reader_params=reader_params) self.get_chunk_path = get_chunk_path_callback def read_chunk(self, chunk_id: int) -> DataWithMime: @@ -82,9 +95,13 @@ def read_chunk(self, chunk_id: int) -> DataWithMime: class _BufferChunkLoader(_ChunkLoader): def __init__( - self, reader_class: Type[IMediaReader], get_chunk_callback: Callable[[int], DataWithMime] + self, + reader_class: Type[IMediaReader], + get_chunk_callback: Callable[[int], DataWithMime], + *, + reader_params: Optional[dict] = None, ) -> None: - super().__init__(reader_class) + super().__init__(reader_class, reader_params=reader_params) self.get_chunk = get_chunk_callback def read_chunk(self, chunk_id: int) -> DataWithMime: @@ -359,9 +376,14 @@ def __init__(self, db_segment: models.Segment) -> None: db_data = db_segment.task.data - reader_class: dict[models.DataChoice, Type[IMediaReader]] = { - models.DataChoice.IMAGESET: ZipReader, - models.DataChoice.VIDEO: VideoReader, + reader_class: dict[models.DataChoice, Tuple[Type[IMediaReader], Optional[dict]]] = { + models.DataChoice.IMAGESET: (ZipReader, None), + models.DataChoice.VIDEO: (VideoReader, { + "allow_threading": False + # disable threading to avoid unpredictable server + # resource consumption during reading in endpoints + # can be enabled for other clients + }), } self._loaders: dict[FrameQuality, _ChunkLoader] = {} @@ -369,28 +391,32 @@ def __init__(self, db_segment: models.Segment) -> None: cache = MediaCache() self._loaders[FrameQuality.COMPRESSED] = _BufferChunkLoader( - reader_class=reader_class[db_data.compressed_chunk_type], + reader_class=reader_class[db_data.compressed_chunk_type][0], + reader_params=reader_class[db_data.compressed_chunk_type][1], get_chunk_callback=lambda chunk_idx: cache.get_segment_chunk( db_segment, chunk_idx, quality=FrameQuality.COMPRESSED ), ) self._loaders[FrameQuality.ORIGINAL] = _BufferChunkLoader( - reader_class=reader_class[db_data.original_chunk_type], + reader_class=reader_class[db_data.original_chunk_type][0], + reader_params=reader_class[db_data.original_chunk_type][1], get_chunk_callback=lambda chunk_idx: cache.get_segment_chunk( db_segment, chunk_idx, quality=FrameQuality.ORIGINAL ), ) else: self._loaders[FrameQuality.COMPRESSED] = _FileChunkLoader( - reader_class=reader_class[db_data.compressed_chunk_type], + reader_class=reader_class[db_data.compressed_chunk_type][0], + reader_params=reader_class[db_data.compressed_chunk_type][1], get_chunk_path_callback=lambda chunk_idx: db_data.get_compressed_segment_chunk_path( chunk_idx, segment=db_segment.id ), ) self._loaders[FrameQuality.ORIGINAL] = _FileChunkLoader( - reader_class=reader_class[db_data.original_chunk_type], + reader_class=reader_class[db_data.original_chunk_type][0], + reader_params=reader_class[db_data.original_chunk_type][1], get_chunk_path_callback=lambda chunk_idx: db_data.get_original_segment_chunk_path( chunk_idx, segment=db_segment.id ), diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index c7314bcf4b6a..ef2bd8e0e9e7 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -686,7 +686,7 @@ def __init__(self, manifest_path: str, source_path: str, *, allow_threading: boo @contextmanager def _read_av_container(self): - container = av.open(self._source_path[0]) + container = av.open(self._source_path) try: yield container finally: From d512312508c7ef5915d302e01c4bd50e0bd8b3b8 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 6 Aug 2024 14:11:43 +0300 Subject: [PATCH 022/227] Fix keyframe search --- cvat/apps/engine/media_extractors.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index ef2bd8e0e9e7..411707360558 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -703,8 +703,12 @@ def _get_nearest_left_key_frame(self, frame_id: int) -> tuple[int, int]: nearest_left_keyframe_pos = bisect( self._manifest, frame_id, key=lambda entry: entry.get('number') ) - frame_number = self._manifest[nearest_left_keyframe_pos].get('number') - timestamp = self._manifest[nearest_left_keyframe_pos].get('pts') + if nearest_left_keyframe_pos: + frame_number = self._manifest[nearest_left_keyframe_pos - 1].get('number') + timestamp = self._manifest[nearest_left_keyframe_pos - 1].get('pts') + else: + frame_number = 0 + timestamp = 0 return frame_number, timestamp def iterate_frames(self, frame_ids: Iterable[int]) -> Iterable[av.VideoFrame]: @@ -744,10 +748,12 @@ def iterate_frames(self, frame_ids: Iterable[int]) -> Iterable[av.VideoFrame]: ) yield frame - else: + frame_ids_frame = next(frame_ids_iter, None) - if frame_ids_frame is None: - return + + if frame_ids_frame is None: + return + class IChunkWriter(ABC): def __init__(self, quality, dimension=DimensionType.DIM_2D): From 167ee12b43471486f5db2856a55aadcc4f09a89d Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 6 Aug 2024 14:56:20 +0300 Subject: [PATCH 023/227] Return frames as generator in dynamic chunk creation --- cvat/apps/engine/cache.py | 63 +++++++++++++--------------- cvat/apps/engine/frame_provider.py | 15 ++++--- cvat/apps/engine/media_extractors.py | 4 +- 3 files changed, 39 insertions(+), 43 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 521086296bb7..66c083853406 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -11,10 +11,10 @@ import tempfile import zipfile import zlib -from contextlib import ExitStack, contextmanager +from contextlib import ExitStack, closing from datetime import datetime, timezone from itertools import pairwise -from typing import Any, Callable, Iterable, Optional, Sequence, Tuple, Type, Union +from typing import Any, Callable, Iterator, Optional, Sequence, Tuple, Type, Union import av import cv2 @@ -149,10 +149,9 @@ def get_frame_context_images(self, db_data: models.Data, frame_number: int) -> D create_callback=lambda: self._prepare_context_image(db_data, frame_number), ) - @contextmanager def _read_raw_frames( self, db_task: models.Task, frame_ids: Sequence[int] - ) -> Iterable[Tuple[Union[av.VideoFrame, PIL.Image.Image], str, str]]: + ) -> Iterator[Tuple[Union[av.VideoFrame, PIL.Image.Image], str, str]]: for prev_frame, cur_frame in pairwise(frame_ids): assert ( prev_frame <= cur_frame @@ -168,20 +167,19 @@ def _read_raw_frames( dimension = db_task.dimension - media = [] - with ExitStack() as es: - if hasattr(db_data, "video"): - source_path = os.path.join(raw_data_dir, db_data.video.path) - - reader = VideoReaderWithManifest( - manifest_path=db_data.get_manifest_path(), - source_path=source_path, - ) - for frame in reader.iterate_frames(frame_ids): - media.append((frame, source_path, None)) - else: - reader = ImageReaderWithManifest(db_data.get_manifest_path()) - if db_data.storage == models.StorageChoice.CLOUD_STORAGE: + if hasattr(db_data, "video"): + source_path = os.path.join(raw_data_dir, db_data.video.path) + + reader = VideoReaderWithManifest( + manifest_path=db_data.get_manifest_path(), + source_path=source_path, + ) + for frame in reader.iterate_frames(frame_ids): + yield (frame, source_path, None) + else: + reader = ImageReaderWithManifest(db_data.get_manifest_path()) + if db_data.storage == models.StorageChoice.CLOUD_STORAGE: + with ExitStack() as es: db_cloud_storage = db_data.cloud_storage assert db_cloud_storage, "Cloud storage instance was deleted" credentials = Credentials() @@ -221,17 +219,17 @@ def _read_raw_frames( slogger.cloud_storage[db_cloud_storage.id].warning( "Hash sums of files {} do not match".format(file_name) ) - else: - for item in reader.iterate_frames(frame_ids): - source_path = os.path.join( - raw_data_dir, f"{item['name']}{item['extension']}" - ) - media.append((source_path, source_path, None)) - if dimension == models.DimensionType.DIM_2D: - media = preload_images(media) + yield from media + else: + for item in reader.iterate_frames(frame_ids): + source_path = os.path.join(raw_data_dir, f"{item['name']}{item['extension']}") + media.append((source_path, source_path, None)) - yield media + if dimension == models.DimensionType.DIM_2D: + media = preload_images(media) + + yield from media def prepare_segment_chunk( self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality @@ -269,11 +267,6 @@ def prepare_range_segment_chunk( ), } - image_quality = ( - 100 - if writer_classes[quality] in [Mpeg4ChunkWriter, ZipChunkWriter] - else db_data.image_quality - ) mime_type = ( "video/mp4" if writer_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] @@ -283,11 +276,11 @@ def prepare_range_segment_chunk( kwargs = {} if db_segment.task.dimension == models.DimensionType.DIM_3D: kwargs["dimension"] = models.DimensionType.DIM_3D - writer = writer_classes[quality](image_quality, **kwargs) + writer = writer_classes[quality](int(quality), **kwargs) buffer = io.BytesIO() - with self._read_raw_frames(db_task, frame_ids=chunk_frame_ids) as images: - writer.save_as_chunk(images, buffer) + with closing(self._read_raw_frames(db_task, frame_ids=chunk_frame_ids)) as frame_iter: + writer.save_as_chunk(frame_iter, buffer) buffer.seek(0) return buffer, mime_type diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index e06be42d64ab..6e5980527bbb 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -378,12 +378,15 @@ def __init__(self, db_segment: models.Segment) -> None: reader_class: dict[models.DataChoice, Tuple[Type[IMediaReader], Optional[dict]]] = { models.DataChoice.IMAGESET: (ZipReader, None), - models.DataChoice.VIDEO: (VideoReader, { - "allow_threading": False - # disable threading to avoid unpredictable server - # resource consumption during reading in endpoints - # can be enabled for other clients - }), + models.DataChoice.VIDEO: ( + VideoReader, + { + "allow_threading": False + # disable threading to avoid unpredictable server + # resource consumption during reading in endpoints + # can be enabled for other clients + }, + ), } self._loaders: dict[FrameQuality, _ChunkLoader] = {} diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 411707360558..b1049cbdd37b 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -827,7 +827,7 @@ def _write_pcd_file(self, image: str|io.BytesIO) -> tuple[io.BytesIO, str, int, if isinstance(image, str): image_buf.close() - def save_as_chunk(self, images: Iterable[tuple[Image.Image|io.IOBase|str, str, str]], chunk_path: str): + def save_as_chunk(self, images: Iterator[tuple[Image.Image|io.IOBase|str, str, str]], chunk_path: str): with zipfile.ZipFile(chunk_path, 'x') as zip_chunk: for idx, (image, path, _) in enumerate(images): ext = os.path.splitext(path)[1].replace('.', '') @@ -872,7 +872,7 @@ def save_as_chunk(self, images: Iterable[tuple[Image.Image|io.IOBase|str, str, s class ZipCompressedChunkWriter(ZipChunkWriter): def save_as_chunk( self, - images: Iterable[tuple[Image.Image|io.IOBase|str, str, str]], + images: Iterator[tuple[Image.Image|io.IOBase|str, str, str]], chunk_path: str, *, compress_frames: bool = True, zip_compress_level: int = 0 ): image_sizes = [] From 88a9cb258e067efea269bccba1a510bd88cf2437 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 7 Aug 2024 12:29:56 +0300 Subject: [PATCH 024/227] Update chunk requests in UI --- cvat-core/src/frames.ts | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index 8aab9f7f0ebe..7dd2ccc06fa5 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -25,7 +25,7 @@ const frameDataCache: Record | null; activeContextRequest: Promise> | null; @@ -208,10 +208,12 @@ export class FrameData { class PrefetchAnalyzer { #chunkSize: number; #requestedFrames: number[]; + #startFrame: number; - constructor(chunkSize) { + constructor(chunkSize, startFrame) { this.#chunkSize = chunkSize; this.#requestedFrames = []; + this.#startFrame = startFrame; } shouldPrefetchNext(current: number, isPlaying: boolean, isChunkCached: (chunk) => boolean): boolean { @@ -219,13 +221,13 @@ class PrefetchAnalyzer { return true; } - const currentChunk = Math.floor(current / this.#chunkSize); + const currentChunk = Math.floor((current - this.#startFrame) / this.#chunkSize); const { length } = this.#requestedFrames; const isIncreasingOrder = this.#requestedFrames .every((val, index) => index === 0 || val > this.#requestedFrames[index - 1]); if ( length && (isIncreasingOrder && current > this.#requestedFrames[length - 1]) && - (current % this.#chunkSize) >= Math.ceil(this.#chunkSize / 2) && + ((current - this.#startFrame) % this.#chunkSize) >= Math.ceil(this.#chunkSize / 2) && !isChunkCached(currentChunk + 1) ) { // is increasing order including the current frame @@ -262,19 +264,20 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { imageData: ImageBitmap | Blob; } | Blob>((resolve, reject) => { const { - provider, prefetchAnalizer, chunkSize, stopFrame, decodeForward, forwardStep, decodedBlocksCacheSize, + provider, prefetchAnalyzer, chunkSize, startFrame, stopFrame, + decodeForward, forwardStep, decodedBlocksCacheSize, } = frameDataCache[this.jobID]; const requestId = +_.uniqueId(); - const chunkNumber = Math.floor(this.number / chunkSize); + const chunkNumber = Math.floor((this.number - startFrame) / chunkSize); const frame = provider.frame(this.number); function findTheNextNotDecodedChunk(searchFrom: number): number { let firstFrameInNextChunk = searchFrom + forwardStep; - let nextChunkNumber = Math.floor(firstFrameInNextChunk / chunkSize); + let nextChunkNumber = Math.floor((firstFrameInNextChunk - startFrame) / chunkSize); while (nextChunkNumber === chunkNumber) { firstFrameInNextChunk += forwardStep; - nextChunkNumber = Math.floor(firstFrameInNextChunk / chunkSize); + nextChunkNumber = Math.floor((firstFrameInNextChunk - startFrame) / chunkSize); } if (provider.isChunkCached(nextChunkNumber)) { @@ -286,7 +289,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { if (frame) { if ( - prefetchAnalizer.shouldPrefetchNext( + prefetchAnalyzer.shouldPrefetchNext( this.number, decodeForward, (chunk) => provider.isChunkCached(chunk), @@ -294,7 +297,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { ) { const nextChunkNumber = findTheNextNotDecodedChunk(this.number); const predecodeChunksMax = Math.floor(decodedBlocksCacheSize / 2); - if (nextChunkNumber * chunkSize <= stopFrame && + if (startFrame + nextChunkNumber * chunkSize <= stopFrame && nextChunkNumber <= chunkNumber + predecodeChunksMax ) { frameDataCache[this.jobID].activeChunkRequest = new Promise((resolveForward) => { @@ -316,8 +319,8 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { provider.cleanup(1); provider.requestDecodeBlock( chunk, - nextChunkNumber * chunkSize, - Math.min(stopFrame, (nextChunkNumber + 1) * chunkSize - 1), + startFrame + nextChunkNumber * chunkSize, + Math.min(stopFrame, startFrame + (nextChunkNumber + 1) * chunkSize - 1), () => {}, releasePromise, releasePromise, @@ -334,7 +337,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { renderHeight: this.height, imageData: frame, }); - prefetchAnalizer.addRequested(this.number); + prefetchAnalyzer.addRequested(this.number); return; } @@ -355,7 +358,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { renderHeight: this.height, imageData: currentFrame, }); - prefetchAnalizer.addRequested(this.number); + prefetchAnalyzer.addRequested(this.number); return; } @@ -378,8 +381,8 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { provider .requestDecodeBlock( chunk, - chunkNumber * chunkSize, - Math.min(stopFrame, (chunkNumber + 1) * chunkSize - 1), + startFrame + chunkNumber * chunkSize, + Math.min(stopFrame, startFrame + (chunkNumber + 1) * chunkSize - 1), (_frame: number, bitmap: ImageBitmap | Blob) => { if (decodeForward) { // resolve immediately only if is not playing @@ -395,7 +398,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { renderHeight: this.height, imageData: bitmap, }); - prefetchAnalizer.addRequested(this.number); + prefetchAnalyzer.addRequested(this.number); } }, () => { frameDataCache[this.jobID].activeChunkRequest = null; @@ -614,7 +617,7 @@ export async function getFrame( decodedBlocksCacheSize, dimension, ), - prefetchAnalizer: new PrefetchAnalyzer(chunkSize), + prefetchAnalyzer: new PrefetchAnalyzer(chunkSize, startFrame), decodedBlocksCacheSize, activeChunkRequest: null, activeContextRequest: null, From 30bf8fd8c3bb41dc8ffaca0fbf690d59dd538cb0 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 7 Aug 2024 14:27:13 +0300 Subject: [PATCH 025/227] Update cache indices in FrameDecoder, enable video play --- cvat-core/src/frames.ts | 1 + cvat-data/src/ts/cvat-data.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index 7dd2ccc06fa5..ff6200ce91be 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -615,6 +615,7 @@ export async function getFrame( blockType, chunkSize, decodedBlocksCacheSize, + startFrame, dimension, ), prefetchAnalyzer: new PrefetchAnalyzer(chunkSize, startFrame), diff --git a/cvat-data/src/ts/cvat-data.ts b/cvat-data/src/ts/cvat-data.ts index 2f832ac9d3f5..ec9fc5cccc58 100644 --- a/cvat-data/src/ts/cvat-data.ts +++ b/cvat-data/src/ts/cvat-data.ts @@ -100,11 +100,13 @@ export class FrameDecoder { private renderHeight: number; private zipWorker: Worker | null; private videoWorker: Worker | null; + private startFrame: number; constructor( blockType: BlockType, chunkSize: number, cachedBlockCount: number, + startFrame: number, dimension: DimensionType = DimensionType.DIMENSION_2D, ) { this.mutex = new Mutex(); @@ -118,6 +120,7 @@ export class FrameDecoder { this.renderWidth = 1920; this.renderHeight = 1080; this.chunkSize = chunkSize; + this.startFrame = startFrame; this.blockType = blockType; this.decodedChunks = {}; @@ -203,7 +206,7 @@ export class FrameDecoder { } frame(frameNumber: number): ImageBitmap | Blob | null { - const chunkNumber = Math.floor(frameNumber / this.chunkSize); + const chunkNumber = Math.floor((frameNumber - this.startFrame) / this.chunkSize); if (chunkNumber in this.decodedChunks) { return this.decodedChunks[chunkNumber][frameNumber]; } @@ -262,7 +265,7 @@ export class FrameDecoder { throw new RequestOutdatedError(); } - const chunkNumber = Math.floor(start / this.chunkSize); + const chunkNumber = Math.floor((start - this.startFrame) / this.chunkSize); this.orderedStack = [chunkNumber, ...this.orderedStack]; this.cleanup(); const decodedFrames: Record = {}; From ee3c905debc918fc5514ef3049d231486cced833 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 7 Aug 2024 14:27:35 +0300 Subject: [PATCH 026/227] Fix frame retrieval for video --- cvat/apps/engine/frame_provider.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 6e5980527bbb..078f626cd56d 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -142,14 +142,18 @@ def _av_frame_to_png_bytes(cls, av_frame: av.VideoFrame) -> BytesIO: return BytesIO(result.tobytes()) def _convert_frame( - self, frame: Any, reader: IMediaReader, out_type: FrameOutputType + self, frame: Any, reader_class: Type[IMediaReader], out_type: FrameOutputType ) -> AnyFrame: if out_type == FrameOutputType.BUFFER: - return self._av_frame_to_png_bytes(frame) if isinstance(reader, VideoReader) else frame + return ( + self._av_frame_to_png_bytes(frame) + if issubclass(reader_class, VideoReader) + else frame + ) elif out_type == FrameOutputType.PIL: - return frame.to_image() if isinstance(reader, VideoReader) else Image.open(frame) + return frame.to_image() if issubclass(reader_class, VideoReader) else Image.open(frame) elif out_type == FrameOutputType.NUMPY_ARRAY: - if isinstance(reader, VideoReader): + if issubclass(reader_class, VideoReader): image = frame.to_ndarray(format="bgr24") else: image = np.array(Image.open(frame)) @@ -358,6 +362,9 @@ def iterate_frames( yield self.get_frame(idx, quality=quality, out_type=out_type) def _get_segment(self, validated_frame_number: int) -> models.Segment: + if not self._db_task.data or not self._db_task.data.size: + raise ValidationError("Task has no data") + return next( s for s in self._db_task.segment_set.all() @@ -474,12 +481,12 @@ def _get_raw_frame( frame_number: int, *, quality: FrameQuality = FrameQuality.ORIGINAL, - ) -> Tuple[Any, str, IMediaReader]: + ) -> Tuple[Any, str, Type[IMediaReader]]: _, chunk_number, frame_offset = self.validate_frame_number(frame_number) loader = self._loaders[quality] chunk_reader = loader.load(chunk_number) frame, frame_name, _ = chunk_reader[frame_offset] - return frame, frame_name, chunk_reader + return frame, frame_name, loader.reader_class def get_frame( self, @@ -490,10 +497,10 @@ def get_frame( ) -> DataWithMeta[AnyFrame]: return_type = DataWithMeta[AnyFrame] - frame, frame_name, reader = self._get_raw_frame(frame_number, quality=quality) + frame, frame_name, reader_class = self._get_raw_frame(frame_number, quality=quality) - frame = self._convert_frame(frame, reader, out_type) - if isinstance(reader, VideoReader): + frame = self._convert_frame(frame, reader_class, out_type) + if issubclass(reader_class, VideoReader): return return_type(frame, mime=self.VIDEO_FRAME_MIME) return return_type(frame, mime=mimetypes.guess_type(frame_name)[0]) From dc03220c02cdee7997549c3be8e470b519cfbd16 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 7 Aug 2024 16:45:17 +0300 Subject: [PATCH 027/227] Fix frame reading in updated dynamic cache building --- cvat/apps/engine/cache.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 66c083853406..b374213cb8e2 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -201,6 +201,7 @@ def _read_raw_frames( tmp_dir = es.enter_context(tempfile.TemporaryDirectory(prefix="cvat")) files_to_download = [] checksums = [] + media = [] for item in reader.iterate_frames(frame_ids): file_name = f"{item['name']}{item['extension']}" fs_filename = os.path.join(tmp_dir, file_name) @@ -222,6 +223,7 @@ def _read_raw_frames( yield from media else: + media = [] for item in reader.iterate_frames(frame_ids): source_path = os.path.join(raw_data_dir, f"{item['name']}{item['extension']}") media.append((source_path, source_path, None)) From 4bb8a74e24b0f9dc759ecdfdc987818440db0009 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 9 Aug 2024 17:29:28 +0300 Subject: [PATCH 028/227] Fix invalid frame quality --- cvat/apps/engine/cache.py | 4 +++- cvat/apps/engine/frame_provider.py | 4 +++- cvat/apps/engine/task.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index b374213cb8e2..204fdc251e31 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -269,6 +269,8 @@ def prepare_range_segment_chunk( ), } + image_quality = 100 if quality == FrameQuality.ORIGINAL else db_data.image_quality + mime_type = ( "video/mp4" if writer_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] @@ -278,7 +280,7 @@ def prepare_range_segment_chunk( kwargs = {} if db_segment.task.dimension == models.DimensionType.DIM_3D: kwargs["dimension"] = models.DimensionType.DIM_3D - writer = writer_classes[quality](int(quality), **kwargs) + writer = writer_classes[quality](image_quality, **kwargs) buffer = io.BytesIO() with closing(self._read_raw_frames(db_task, frame_ids=chunk_frame_ids)) as frame_iter: diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 078f626cd56d..93a4e747c51f 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -292,6 +292,8 @@ def get_chunk( writer_class = writer_classes[quality] + image_quality = 100 if quality == FrameQuality.ORIGINAL else db_data.image_quality + mime_type = ( "video/mp4" if writer_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] @@ -318,7 +320,7 @@ def get_chunk( writer_kwargs = {} if self._db_task.dimension == models.DimensionType.DIM_3D: writer_kwargs["dimension"] = models.DimensionType.DIM_3D - merged_chunk_writer = writer_class(int(quality), **writer_kwargs) + merged_chunk_writer = writer_class(image_quality, **writer_kwargs) writer_kwargs = {} if isinstance(merged_chunk_writer, ZipCompressedChunkWriter): diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 0416704b9136..a0422ee9c29c 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1202,7 +1202,7 @@ def save_chunks( # Let's use QP=17 (that is 67 for 0-100 range) for the original chunks, # which should be visually lossless or nearly so. # A lower value will significantly increase the chunk size with a slight increase of quality. - original_quality = 67 + original_quality = 67 # TODO: fix discrepancy in values in different parts of code else: original_chunk_writer_class = ZipChunkWriter original_quality = 100 From f7d2c4c2c12e302c0af8e7618dfe64b15ad951be Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 9 Aug 2024 17:32:21 +0300 Subject: [PATCH 029/227] Fix video reading in media_extractors - exception handling, frame mismatches --- cvat/apps/engine/media_extractors.py | 187 ++++++++++++++++----------- 1 file changed, 115 insertions(+), 72 deletions(-) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index b1049cbdd37b..3cb613fadbe9 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -18,7 +18,7 @@ from contextlib import ExitStack, closing, contextmanager from dataclasses import dataclass from enum import IntEnum -from typing import Any, Callable, Iterable, Iterator, Optional, Protocol, Sequence, Tuple, TypeVar +from typing import Any, Callable, Iterable, Iterator, Optional, Protocol, Sequence, Tuple, TypeVar, Union import av import av.codec @@ -507,8 +507,14 @@ def extract(self): class VideoReader(IMediaReader): def __init__( - self, source_path, step=1, start=0, stop=None, - dimension=DimensionType.DIM_2D, *, allow_threading: bool = True + self, + source_path: Union[str, io.BytesIO], + step: int = 1, + start: int = 0, + stop: Optional[int] = None, + dimension: DimensionType = DimensionType.DIM_2D, + *, + allow_threading: bool = True, ): super().__init__( source_path=source_path, @@ -522,65 +528,88 @@ def __init__( self._frame_count: Optional[int] = None self._frame_size: Optional[tuple[int, int]] = None # (w, h) - def _has_frame(self, i): - if i >= self._start: - if (i - self._start) % self._step == 0: - if self._stop is None or i < self._stop: - return True - - return False - - def _make_frame_iterator( + def iterate_frames( self, *, - apply_filter: bool = True, - stream: Optional[av.video.stream.VideoStream] = None, + frame_filter: Union[bool, Iterable[int]] = True, + video_stream: Optional[av.video.stream.VideoStream] = None, ) -> Iterator[Tuple[av.VideoFrame, str, int]]: + """ + If provided, frame_filter must be an ordered sequence in the ascending order. + 'True' means using the frames configured in the reader object. + 'False' or 'None' means returning all the video frames. + """ + + if frame_filter is True: + frame_filter = itertools.count(self._start, self._step) + if self._stop: + frame_filter = itertools.takewhile(lambda x: x <= self._stop, frame_filter) + elif not frame_filter: + frame_filter = itertools.count() + + frame_filter_iter = iter(frame_filter) + next_frame_filter_frame = next(frame_filter_iter, None) + if next_frame_filter_frame is None: + return + es = ExitStack() - needs_init = stream is None + needs_init = video_stream is None if needs_init: container = es.enter_context(self._read_av_container()) else: - container = stream.container + container = video_stream.container with es: if needs_init: - stream = container.streams.video[0] + video_stream = container.streams.video[0] if self.allow_threading: - stream.thread_type = 'AUTO' + video_stream.thread_type = 'AUTO' - frame_num = 0 + exception = None + frame_number = 0 + for packet in container.demux(video_stream): + try: + for frame in packet.decode(): + if frame_number == next_frame_filter_frame: + if video_stream.metadata.get('rotate'): + pts = frame.pts + frame = av.VideoFrame().from_ndarray( + rotate_image( + frame.to_ndarray(format='bgr24'), + 360 - int(video_stream.metadata.get('rotate')) + ), + format ='bgr24' + ) + frame.pts = pts - for packet in container.demux(stream): - for image in packet.decode(): - frame_num += 1 + if self._frame_size is None: + self._frame_size = (frame.width, frame.height) - if apply_filter and not self._has_frame(frame_num - 1): - continue + yield (frame, self._source_path[0], frame.pts) - if stream.metadata.get('rotate'): - pts = image.pts - image = av.VideoFrame().from_ndarray( - rotate_image( - image.to_ndarray(format='bgr24'), - 360 - int(stream.metadata.get('rotate')) - ), - format ='bgr24' - ) - image.pts = pts + next_frame_filter_frame = next(frame_filter_iter, None) - if self._frame_size is None: - self._frame_size = (image.width, image.height) + if next_frame_filter_frame is None: + return - yield (image, self._source_path[0], image.pts) + frame_number += 1 + except Exception as e: + if av.__version__ == "9.2.0": + # av v9.2.0 seems to have a memory corruption + # in exception handling for demux() in the multithreaded mode. + # Instead of breaking the iteration, we iterate over packets till the end. + # Fixed in v12.2.0. + exception = e + if video_stream.thread_type != 'AUTO': + break - if self._frame_count is None: - self._frame_count = frame_num + if exception: + raise exception def __iter__(self): - return self._make_frame_iterator() + return self.iterate_frames() def get_progress(self, pos): duration = self._get_duration() @@ -602,7 +631,8 @@ def _read_av_container(self): if context and context.is_open: context.close() - container.close() + if container.open_files: + container.close() def _get_duration(self): with self._read_av_container() as container: @@ -629,7 +659,7 @@ def get_preview(self, frame): needed_time = int((frame / stream.guessed_rate) * tb_denominator) container.seek(offset=needed_time, stream=stream) - with closing(self._make_frame_iterator(stream=stream)) as frame_iter: + with closing(self.iterate_frames(video_stream=stream)) as frame_iter: return self._get_preview(next(frame_iter)) def get_image_size(self, i): @@ -637,8 +667,8 @@ def get_image_size(self, i): return self._frame_size with closing(iter(self)) as frame_iter: - image = next(frame_iter)[0] - self._frame_size = (image.width, image.height) + frame = next(frame_iter)[0] + self._frame_size = (frame.width, frame.height) return self._frame_size @@ -659,7 +689,7 @@ def get_frame_count(self) -> int: return self._frame_count frame_count = 0 - for _ in self._make_frame_iterator(apply_filter=False): + for _ in self.iterate_frames(frame_filter=False): frame_count += 1 self._frame_count = frame_count @@ -677,10 +707,13 @@ def iterate_frames(self, frame_ids: Iterable[int]): yield self._manifest[idx] class VideoReaderWithManifest: + # TODO: merge this class with VideoReader + def __init__(self, manifest_path: str, source_path: str, *, allow_threading: bool = False): self._source_path = source_path self._manifest = VideoManifestManager(manifest_path) - self._manifest.init_index() + if self._manifest.exists: + self._manifest.init_index() self.allow_threading = allow_threading @@ -711,49 +744,59 @@ def _get_nearest_left_key_frame(self, frame_id: int) -> tuple[int, int]: timestamp = 0 return frame_number, timestamp - def iterate_frames(self, frame_ids: Iterable[int]) -> Iterable[av.VideoFrame]: + def iterate_frames(self, *, frame_filter: Iterable[int]) -> Iterable[av.VideoFrame]: "frame_ids must be an ordered sequence in the ascending order" - frame_ids_iter = iter(frame_ids) - frame_ids_frame = next(frame_ids_iter, None) - if frame_ids_frame is None: + frame_filter_iter = iter(frame_filter) + next_frame_filter_frame = next(frame_filter_iter, None) + if next_frame_filter_frame is None: return start_decode_frame_number, start_decode_timestamp = self._get_nearest_left_key_frame( - frame_ids_frame + next_frame_filter_frame ) with self._read_av_container() as container: - stream = container.streams.video[0] + video_stream = container.streams.video[0] if self.allow_threading: - stream.thread_type = 'AUTO' + video_stream.thread_type = 'AUTO' - container.seek(offset=start_decode_timestamp, stream=stream) + container.seek(offset=start_decode_timestamp, stream=video_stream) frame_number = start_decode_frame_number - 1 - for packet in container.demux(stream): - for frame in packet.decode(): - frame_number += 1 + for packet in container.demux(video_stream): + try: + for frame in packet.decode(): + if frame_number == next_frame_filter_frame: + if video_stream.metadata.get('rotate'): + frame = av.VideoFrame().from_ndarray( + rotate_image( + frame.to_ndarray(format='bgr24'), + 360 - int(video_stream.metadata.get('rotate')) + ), + format ='bgr24' + ) - if frame_number < frame_ids_frame: - continue - elif frame_number == frame_ids_frame: - if stream.metadata.get('rotate'): - frame = av.VideoFrame().from_ndarray( - rotate_image( - frame.to_ndarray(format='bgr24'), - 360 - int(container.streams.video[0].metadata.get('rotate')) - ), - format ='bgr24' - ) + yield frame - yield frame + next_frame_filter_frame = next(frame_filter_iter, None) - frame_ids_frame = next(frame_ids_iter, None) + if next_frame_filter_frame is None: + return - if frame_ids_frame is None: - return + frame_number += 1 + except Exception as e: + if av.__version__ == "9.2.0": + # av v9.2.0 seems to have a memory corruption + # in exception handling for demux() in the multithreaded mode. + # Instead of breaking the iteration, we iterate over packets till the end. + # Fixed in v12.2.0. + exception = e + if video_stream.thread_type != 'AUTO': + break + if exception: + raise exception class IChunkWriter(ABC): def __init__(self, quality, dimension=DimensionType.DIM_2D): From 34d9ca0be23a1e4b9ce4b2b467ef0017e937db74 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 9 Aug 2024 17:32:47 +0300 Subject: [PATCH 030/227] Allow disabling static chunks, add seamless switching --- cvat/apps/engine/apps.py | 8 +++ cvat/apps/engine/cache.py | 35 ++++++++--- cvat/apps/engine/default_settings.py | 16 ++++++ cvat/apps/engine/frame_provider.py | 7 ++- cvat/apps/engine/task.py | 86 ++++++++++++++-------------- 5 files changed, 102 insertions(+), 50 deletions(-) create mode 100644 cvat/apps/engine/default_settings.py diff --git a/cvat/apps/engine/apps.py b/cvat/apps/engine/apps.py index 326920e8b494..bcad84510f5d 100644 --- a/cvat/apps/engine/apps.py +++ b/cvat/apps/engine/apps.py @@ -10,6 +10,14 @@ class EngineConfig(AppConfig): name = 'cvat.apps.engine' def ready(self): + from django.conf import settings + + from . import default_settings + + for key in dir(default_settings): + if key.isupper() and not hasattr(settings, key): + setattr(settings, key, getattr(default_settings, key)) + # Required to define signals in application import cvat.apps.engine.signals # Required in order to silent "unused-import" in pyflake diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 204fdc251e31..6295cbb0e36e 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -7,6 +7,7 @@ import io import os +import os.path import pickle # nosec import tempfile import zipfile @@ -37,6 +38,7 @@ ImageReaderWithManifest, Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter, + VideoReader, VideoReaderWithManifest, ZipChunkWriter, ZipCompressedChunkWriter, @@ -166,19 +168,38 @@ def _read_raw_frames( }[db_data.storage] dimension = db_task.dimension + manifest_path = db_data.get_manifest_path() if hasattr(db_data, "video"): source_path = os.path.join(raw_data_dir, db_data.video.path) reader = VideoReaderWithManifest( - manifest_path=db_data.get_manifest_path(), + manifest_path=manifest_path, source_path=source_path, + allow_threading=False, ) - for frame in reader.iterate_frames(frame_ids): - yield (frame, source_path, None) + if not os.path.isfile(manifest_path): + try: + reader._manifest.link(source_path, force=True) + reader._manifest.create() + except Exception as e: + # TODO: improve logging + reader = None + + if reader: + for frame in reader.iterate_frames(frame_filter=frame_ids): + yield (frame, source_path, None) + else: + reader = VideoReader([source_path], allow_threading=False) + + for frame_tuple in reader.iterate_frames(frame_filter=frame_ids): + yield frame_tuple else: - reader = ImageReaderWithManifest(db_data.get_manifest_path()) - if db_data.storage == models.StorageChoice.CLOUD_STORAGE: + if ( + os.path.isfile(manifest_path) + and db_data.storage == models.StorageChoice.CLOUD_STORAGE + ): + reader = ImageReaderWithManifest() with ExitStack() as es: db_cloud_storage = db_data.cloud_storage assert db_cloud_storage, "Cloud storage instance was deleted" @@ -224,8 +245,8 @@ def _read_raw_frames( yield from media else: media = [] - for item in reader.iterate_frames(frame_ids): - source_path = os.path.join(raw_data_dir, f"{item['name']}{item['extension']}") + for image in sorted(db_data.images.all(), key=lambda image: image.frame): + source_path = os.path.join(raw_data_dir, image.path) media.append((source_path, source_path, None)) if dimension == models.DimensionType.DIM_2D: diff --git a/cvat/apps/engine/default_settings.py b/cvat/apps/engine/default_settings.py new file mode 100644 index 000000000000..5e74759f6b4c --- /dev/null +++ b/cvat/apps/engine/default_settings.py @@ -0,0 +1,16 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import os + +from attrs.converters import to_bool + +MEDIA_CACHE_ALLOW_STATIC_CHUNKS = to_bool(os.getenv("CVAT_ALLOW_STATIC_CHUNKS", False)) +""" +Allow or disallow static media chunks. +If disabled, CVAT will only use the dynamic media cache. New tasks requesting static media cache +will be automatically switched to the dynamic cache. +When enabled, this option can increase data access speed and reduce server load, +but significantly increase disk space occupied by tasks. +""" diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 93a4e747c51f..b51a61a512f8 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -16,6 +16,7 @@ import av import cv2 import numpy as np +from django.conf import settings from PIL import Image from rest_framework.exceptions import ValidationError @@ -399,7 +400,11 @@ def __init__(self, db_segment: models.Segment) -> None: } self._loaders: dict[FrameQuality, _ChunkLoader] = {} - if db_data.storage_method == models.StorageMethodChoice.CACHE: + if ( + db_data.storage_method == models.StorageMethodChoice.CACHE + or not settings.MEDIA_CACHE_ALLOW_STATIC_CHUNKS + # TODO: separate handling, extract cache creation logic from media cache + ): cache = MediaCache() self._loaders[FrameQuality.COMPRESSED] = _BufferChunkLoader( diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index a0422ee9c29c..4ff62dc1f295 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -953,28 +953,24 @@ def _update_status(msg: str) -> None: # TODO: try to pull up # replace manifest file (e.g was uploaded 'subdir/manifest.jsonl' or 'some_manifest.jsonl') - if ( - settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE and - manifest_file and not os.path.exists(db_data.get_manifest_path()) - ): + if (manifest_file and not os.path.exists(db_data.get_manifest_path())): shutil.copyfile(os.path.join(manifest_root, manifest_file), db_data.get_manifest_path()) if manifest_root and manifest_root.startswith(db_data.get_upload_dirname()): os.remove(os.path.join(manifest_root, manifest_file)) manifest_file = os.path.relpath(db_data.get_manifest_path(), upload_dir) + # Create task frames from the metadata collected video_path: str = "" video_frame_size: tuple[int, int] = (0, 0) images: list[models.Image] = [] - # Collect media metadata for media_type, media_files in media.items(): if not media_files: continue if task_mode == MEDIA_TYPES['video']['mode']: - manifest_is_prepared = False if manifest_file: try: _update_status('Validating the input manifest file') @@ -989,22 +985,21 @@ def _update_status(msg: str) -> None: if not len(manifest): raise ValidationError("No key frames found in the manifest") - video_frame_count = manifest.video_length - video_frame_size = manifest.video_resolution - manifest_is_prepared = True except Exception as ex: manifest.remove() manifest = None - slogger.glob.warning(ex, exc_info=True) if isinstance(ex, (ValidationError, AssertionError)): - _update_status(f'Invalid manifest file was upload: {ex}') + base_msg = f"Invalid manifest file was uploaded: {ex}" + else: + base_msg = "Failed to parse the uploaded manifest file" + slogger.glob.warning(ex, exc_info=True) - if ( - settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE - and not manifest_is_prepared - ): - # TODO: check if we can always use video manifest for optimization + _update_status(base_msg) + else: + manifest = None + + if not manifest: try: _update_status('Preparing a manifest file') @@ -1013,26 +1008,32 @@ def _update_status(msg: str) -> None: manifest.link( media_file=media_files[0], upload_dir=upload_dir, - chunk_size=db_data.chunk_size # TODO: why it's needed here? + chunk_size=db_data.chunk_size, # TODO: why it's needed here? + force=True ) manifest.create() _update_status('A manifest has been created') - video_frame_count = len(manifest.reader) # TODO: check if the field access above and here are equivalent - video_frame_size = manifest.reader.resolution - manifest_is_prepared = True except Exception as ex: manifest.remove() manifest = None - db_data.storage_method = models.StorageMethodChoice.FILE_SYSTEM + if isinstance(ex, AssertionError): + base_msg = f": {ex}" + else: + base_msg = "" + slogger.glob.warning(ex, exc_info=True) - base_msg = str(ex) if isinstance(ex, AssertionError) \ - else "Uploaded video does not support a quick way of task creating." - _update_status("{} The task will be created using the old method".format(base_msg)) + _update_status( + f"Failed to create manifest for the uploaded video{base_msg}. " + "A manifest will not be used in this task" + ) - if not manifest: + if manifest: + video_frame_count = manifest.video_length + video_frame_size = manifest.video_resolution + else: video_frame_count = extractor.get_frame_count() video_frame_size = extractor.get_image_size(0) @@ -1048,22 +1049,20 @@ def _update_status(msg: str) -> None: else: # images, archive, pdf db_data.size = len(extractor) - manifest = None - if settings.USE_CACHE and db_data.storage_method == models.StorageMethodChoice.CACHE: - manifest = ImageManifestManager(db_data.get_manifest_path()) - if not manifest.exists: - manifest.link( - sources=extractor.absolute_source_paths, - meta={ - k: {'related_images': related_images[k] } - for k in related_images - }, - data_dir=upload_dir, - DIM_3D=(db_task.dimension == models.DimensionType.DIM_3D), - ) - manifest.create() - else: - manifest.init_index() + manifest = ImageManifestManager(db_data.get_manifest_path()) + if not manifest.exists: + manifest.link( + sources=extractor.absolute_source_paths, + meta={ + k: {'related_images': related_images[k] } + for k in related_images + }, + data_dir=upload_dir, + DIM_3D=(db_task.dimension == models.DimensionType.DIM_3D), + ) + manifest.create() + else: + manifest.init_index() for frame_id in extractor.frame_range: image_path = extractor.get_path(frame_id) @@ -1128,7 +1127,10 @@ def _update_status(msg: str) -> None: slogger.glob.info("Found frames {} for Data #{}".format(db_data.size, db_data.id)) _create_segments_and_jobs(db_task, job_file_mapping=job_file_mapping) - if db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM or not settings.USE_CACHE: + if ( + settings.MEDIA_CACHE_ALLOW_STATIC_CHUNKS and + db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM + ): _create_static_chunks(db_task, media_extractor=extractor) def _create_static_chunks(db_task: models.Task, *, media_extractor: IMediaReader): From 8c97967bb0b32f72ad97cb594f3392ef7d049c3b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 9 Aug 2024 17:33:55 +0300 Subject: [PATCH 031/227] Extend code formatting --- dev/format_python_code.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/dev/format_python_code.sh b/dev/format_python_code.sh index 10469178f902..8c224c3caaf4 100755 --- a/dev/format_python_code.sh +++ b/dev/format_python_code.sh @@ -25,6 +25,7 @@ for paths in \ "cvat/apps/analytics_report" \ "cvat/apps/engine/frame_provider.py" \ "cvat/apps/engine/cache.py" \ + "cvat/apps/engine/default_settings.py" \ ; do ${BLACK} -- ${paths} ${ISORT} -- ${paths} From a0fd0ba00c89cb1fe61253330ed7add0e7ae8bfa Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 9 Aug 2024 17:34:45 +0300 Subject: [PATCH 032/227] Rename function argument --- cvat/apps/engine/frame_provider.py | 4 ++-- cvat/apps/engine/models.py | 8 ++++---- cvat/apps/engine/task.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index b51a61a512f8..fe55a5df5dc8 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -427,7 +427,7 @@ def __init__(self, db_segment: models.Segment) -> None: reader_class=reader_class[db_data.compressed_chunk_type][0], reader_params=reader_class[db_data.compressed_chunk_type][1], get_chunk_path_callback=lambda chunk_idx: db_data.get_compressed_segment_chunk_path( - chunk_idx, segment=db_segment.id + chunk_idx, segment_id=db_segment.id ), ) @@ -435,7 +435,7 @@ def __init__(self, db_segment: models.Segment) -> None: reader_class=reader_class[db_data.original_chunk_type][0], reader_params=reader_class[db_data.original_chunk_type][1], get_chunk_path_callback=lambda chunk_idx: db_data.get_original_segment_chunk_path( - chunk_idx, segment=db_segment.id + chunk_idx, segment_id=db_segment.id ), ) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index ec0870a0e222..1cc26af3876b 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -272,13 +272,13 @@ def _get_compressed_chunk_name(self, segment_id: int, chunk_number: int) -> str: def _get_original_chunk_name(self, segment_id: int, chunk_number: int) -> str: return self._get_chunk_name(segment_id, chunk_number, self.original_chunk_type) - def get_original_segment_chunk_path(self, chunk_number: int, segment: int) -> str: + def get_original_segment_chunk_path(self, chunk_number: int, segment_id: int) -> str: return os.path.join(self.get_original_cache_dirname(), - self._get_original_chunk_name(segment, chunk_number)) + self._get_original_chunk_name(segment_id, chunk_number)) - def get_compressed_segment_chunk_path(self, chunk_number: int, segment: int) -> str: + def get_compressed_segment_chunk_path(self, chunk_number: int, segment_id: int) -> str: return os.path.join(self.get_compressed_cache_dirname(), - self._get_compressed_chunk_name(segment, chunk_number)) + self._get_compressed_chunk_name(segment_id, chunk_number)) def get_manifest_path(self): return os.path.join(self.get_upload_dirname(), 'manifest.jsonl') diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 4ff62dc1f295..7ea9b7d2aa97 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1179,13 +1179,13 @@ def save_chunks( original_chunk_writer.save_as_chunk, images=chunk_data, chunk_path=db_data.get_original_segment_chunk_path( - chunk_idx, segment=db_segment.id + chunk_idx, segment_id=db_segment.id ), ) compressed_chunk_writer.save_as_chunk( images=chunk_data, chunk_path=db_data.get_compressed_segment_chunk_path( - chunk_idx, segment=db_segment.id + chunk_idx, segment_id=db_segment.id ), ) From c0480c9065d6ff8f55e67929ff2d48308ae19184 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 9 Aug 2024 17:44:31 +0300 Subject: [PATCH 033/227] Rename configuration parameter --- cvat/apps/engine/default_settings.py | 4 ++-- cvat/apps/engine/frame_provider.py | 2 +- cvat/apps/engine/task.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cvat/apps/engine/default_settings.py b/cvat/apps/engine/default_settings.py index 5e74759f6b4c..826fe1c9bef2 100644 --- a/cvat/apps/engine/default_settings.py +++ b/cvat/apps/engine/default_settings.py @@ -6,9 +6,9 @@ from attrs.converters import to_bool -MEDIA_CACHE_ALLOW_STATIC_CHUNKS = to_bool(os.getenv("CVAT_ALLOW_STATIC_CHUNKS", False)) +MEDIA_CACHE_ALLOW_STATIC_CACHE = to_bool(os.getenv("CVAT_ALLOW_STATIC_CACHE", False)) """ -Allow or disallow static media chunks. +Allow or disallow static media cache. If disabled, CVAT will only use the dynamic media cache. New tasks requesting static media cache will be automatically switched to the dynamic cache. When enabled, this option can increase data access speed and reduce server load, diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index fe55a5df5dc8..ac764f00c2a4 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -402,7 +402,7 @@ def __init__(self, db_segment: models.Segment) -> None: self._loaders: dict[FrameQuality, _ChunkLoader] = {} if ( db_data.storage_method == models.StorageMethodChoice.CACHE - or not settings.MEDIA_CACHE_ALLOW_STATIC_CHUNKS + or not settings.MEDIA_CACHE_ALLOW_STATIC_CACHE # TODO: separate handling, extract cache creation logic from media cache ): cache = MediaCache() diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 7ea9b7d2aa97..e021761b5b08 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1128,7 +1128,7 @@ def _update_status(msg: str) -> None: _create_segments_and_jobs(db_task, job_file_mapping=job_file_mapping) if ( - settings.MEDIA_CACHE_ALLOW_STATIC_CHUNKS and + settings.MEDIA_CACHE_ALLOW_STATIC_CACHE and db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM ): _create_static_chunks(db_task, media_extractor=extractor) From 5caf283824580c279fe805dc136487081243ca3a Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 10:56:08 +0300 Subject: [PATCH 034/227] Add av version comment --- cvat/requirements/base.in | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cvat/requirements/base.in b/cvat/requirements/base.in index 6cbfb10321ad..44c5c9b8c879 100644 --- a/cvat/requirements/base.in +++ b/cvat/requirements/base.in @@ -1,7 +1,13 @@ -r ../../utils/dataset_manifest/requirements.in attrs==21.4.0 + +# This is the last version of av that supports ffmpeg we depend on. +# Changing ffmpeg is undesirable, as there might be video decoding differences +# between versions. +# TODO: try to move to the newer version av==9.2.0 + azure-storage-blob==12.13.0 boto3==1.17.61 clickhouse-connect==0.6.8 From efbe3a00b69ffbe0f99bf121e06f122750c359c4 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 12:29:34 +0300 Subject: [PATCH 035/227] Refactor av video reading --- cvat/apps/engine/media_extractors.py | 195 +++++++++++++-------------- 1 file changed, 93 insertions(+), 102 deletions(-) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 3cb613fadbe9..3b843ea3c9cb 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -18,7 +18,10 @@ from contextlib import ExitStack, closing, contextmanager from dataclasses import dataclass from enum import IntEnum -from typing import Any, Callable, Iterable, Iterator, Optional, Protocol, Sequence, Tuple, TypeVar, Union +from typing import ( + Any, Callable, ContextManager, Generator, Iterable, Iterator, Optional, Protocol, + Sequence, Tuple, TypeVar, Union +) import av import av.codec @@ -505,6 +508,43 @@ def extract(self): if not self.extract_dir: os.remove(self._zip_source.filename) +class _AvVideoReading: + @contextmanager + def read_av_container(self, source: Union[str, io.BytesIO]) -> av.container.InputContainer: + if isinstance(source, io.BytesIO): + source.seek(0) # required for re-reading + + container = av.open(source) + try: + yield container + finally: + # fixes a memory leak in input container closing + # https://github.com/PyAV-Org/PyAV/issues/1117 + for stream in container.streams: + context = stream.codec_context + if context and context.is_open: + context.close() + + if container.open_files: + container.close() + + def decode_stream( + self, container: av.container.Container, video_stream: av.video.stream.VideoStream + ) -> Generator[av.VideoFrame, None, None]: + demux_iter = container.demux(video_stream) + try: + for packet in demux_iter: + yield from packet.decode() + finally: + # av v9.2.0 seems to have a memory corruption or a deadlock + # in exception handling for demux() in the multithreaded mode. + # Instead of breaking the iteration, we iterate over packets till the end. + # Fixed in av v12.2.0. + if av.__version__ == "9.2.0" and video_stream.thread_type == 'AUTO': + exhausted = object() + while next(demux_iter, exhausted) is not exhausted: + pass + class VideoReader(IMediaReader): def __init__( self, @@ -567,72 +607,45 @@ def iterate_frames( if self.allow_threading: video_stream.thread_type = 'AUTO' - exception = None - frame_number = 0 - for packet in container.demux(video_stream): - try: - for frame in packet.decode(): - if frame_number == next_frame_filter_frame: - if video_stream.metadata.get('rotate'): - pts = frame.pts - frame = av.VideoFrame().from_ndarray( - rotate_image( - frame.to_ndarray(format='bgr24'), - 360 - int(video_stream.metadata.get('rotate')) - ), - format ='bgr24' - ) - frame.pts = pts + frame_counter = itertools.count() + with closing(self._decode_stream(container, video_stream)) as stream_decoder: + for frame, frame_number in zip(stream_decoder, frame_counter): + if frame_number == next_frame_filter_frame: + if video_stream.metadata.get('rotate'): + pts = frame.pts + frame = av.VideoFrame().from_ndarray( + rotate_image( + frame.to_ndarray(format='bgr24'), + 360 - int(video_stream.metadata.get('rotate')) + ), + format ='bgr24' + ) + frame.pts = pts - if self._frame_size is None: - self._frame_size = (frame.width, frame.height) + if self._frame_size is None: + self._frame_size = (frame.width, frame.height) - yield (frame, self._source_path[0], frame.pts) + yield (frame, self._source_path[0], frame.pts) - next_frame_filter_frame = next(frame_filter_iter, None) + next_frame_filter_frame = next(frame_filter_iter, None) - if next_frame_filter_frame is None: - return + if next_frame_filter_frame is None: + return - frame_number += 1 - except Exception as e: - if av.__version__ == "9.2.0": - # av v9.2.0 seems to have a memory corruption - # in exception handling for demux() in the multithreaded mode. - # Instead of breaking the iteration, we iterate over packets till the end. - # Fixed in v12.2.0. - exception = e - if video_stream.thread_type != 'AUTO': - break - - if exception: - raise exception - - def __iter__(self): + def __iter__(self) -> Iterator[Tuple[av.VideoFrame, str, int]]: return self.iterate_frames() def get_progress(self, pos): duration = self._get_duration() return pos / duration if duration else None - @contextmanager - def _read_av_container(self): - if isinstance(self._source_path[0], io.BytesIO): - self._source_path[0].seek(0) # required for re-reading - - container = av.open(self._source_path[0]) - try: - yield container - finally: - # fixes a memory leak in input container closing - # https://github.com/PyAV-Org/PyAV/issues/1117 - for stream in container.streams: - context = stream.codec_context - if context and context.is_open: - context.close() + def _read_av_container(self) -> ContextManager[av.container.InputContainer]: + return _AvVideoReading().read_av_container(self._source_path[0]) - if container.open_files: - container.close() + def _decode_stream( + self, container: av.container.Container, video_stream: av.video.stream.VideoStream + ) -> Generator[av.VideoFrame, None, None]: + return _AvVideoReading().decode_stream(container, video_stream) def _get_duration(self): with self._read_av_container() as container: @@ -717,20 +730,13 @@ def __init__(self, manifest_path: str, source_path: str, *, allow_threading: boo self.allow_threading = allow_threading - @contextmanager - def _read_av_container(self): - container = av.open(self._source_path) - try: - yield container - finally: - # fixes a memory leak in input container closing - # https://github.com/PyAV-Org/PyAV/issues/1117 - for stream in container.streams: - context = stream.codec_context - if context and context.is_open: - context.close() + def _read_av_container(self) -> ContextManager[av.container.InputContainer]: + return _AvVideoReading().read_av_container(self._source_path) - container.close() + def _decode_stream( + self, container: av.container.Container, video_stream: av.video.stream.VideoStream + ) -> Generator[av.VideoFrame, None, None]: + return _AvVideoReading().decode_stream(container, video_stream) def _get_nearest_left_key_frame(self, frame_id: int) -> tuple[int, int]: nearest_left_keyframe_pos = bisect( @@ -763,40 +769,25 @@ def iterate_frames(self, *, frame_filter: Iterable[int]) -> Iterable[av.VideoFra container.seek(offset=start_decode_timestamp, stream=video_stream) - frame_number = start_decode_frame_number - 1 - for packet in container.demux(video_stream): - try: - for frame in packet.decode(): - if frame_number == next_frame_filter_frame: - if video_stream.metadata.get('rotate'): - frame = av.VideoFrame().from_ndarray( - rotate_image( - frame.to_ndarray(format='bgr24'), - 360 - int(video_stream.metadata.get('rotate')) - ), - format ='bgr24' - ) - - yield frame - - next_frame_filter_frame = next(frame_filter_iter, None) - - if next_frame_filter_frame is None: - return - - frame_number += 1 - except Exception as e: - if av.__version__ == "9.2.0": - # av v9.2.0 seems to have a memory corruption - # in exception handling for demux() in the multithreaded mode. - # Instead of breaking the iteration, we iterate over packets till the end. - # Fixed in v12.2.0. - exception = e - if video_stream.thread_type != 'AUTO': - break - - if exception: - raise exception + frame_counter = itertools.count(start_decode_frame_number - 1) + with closing(self._decode_stream(container, video_stream)) as stream_decoder: + for frame, frame_number in zip(stream_decoder, frame_counter): + if frame_number == next_frame_filter_frame: + if video_stream.metadata.get('rotate'): + frame = av.VideoFrame().from_ndarray( + rotate_image( + frame.to_ndarray(format='bgr24'), + 360 - int(video_stream.metadata.get('rotate')) + ), + format ='bgr24' + ) + + yield frame + + next_frame_filter_frame = next(frame_filter_iter, None) + + if next_frame_filter_frame is None: + return class IChunkWriter(ABC): def __init__(self, quality, dimension=DimensionType.DIM_2D): From fb1284d3c6b8781fda4633a4495b8392fb2f0fd6 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 14:34:14 +0300 Subject: [PATCH 036/227] Fix manifest access --- cvat/apps/engine/cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 6295cbb0e36e..be5c2d799e83 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -199,7 +199,7 @@ def _read_raw_frames( os.path.isfile(manifest_path) and db_data.storage == models.StorageChoice.CLOUD_STORAGE ): - reader = ImageReaderWithManifest() + reader = ImageReaderWithManifest(manifest_path) with ExitStack() as es: db_cloud_storage = db_data.cloud_storage assert db_cloud_storage, "Cloud storage instance was deleted" From 8edcfc53991286da203bc8ae00b2d30f53316988 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 15:27:35 +0300 Subject: [PATCH 037/227] Add migration --- .../migrations/0082_move_to_segment_chunks.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 cvat/apps/engine/migrations/0082_move_to_segment_chunks.py diff --git a/cvat/apps/engine/migrations/0082_move_to_segment_chunks.py b/cvat/apps/engine/migrations/0082_move_to_segment_chunks.py new file mode 100644 index 000000000000..42ee41dd6572 --- /dev/null +++ b/cvat/apps/engine/migrations/0082_move_to_segment_chunks.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.13 on 2024-08-12 09:49 + +import os +from django.db import migrations +from cvat.apps.engine.log import get_migration_logger + +def switch_tasks_with_static_chunks_to_dynamic_chunks(apps, schema_editor): + migration_name = os.path.splitext(os.path.basename(__file__))[0] + with get_migration_logger(migration_name) as common_logger: + Data = apps.get_model("engine", "Data") + + data_with_static_cache_query = ( + Data.objects + .filter(storage_method="file_system") + ) + + data_with_static_cache_ids = list( + v[0] + for v in ( + data_with_static_cache_query + .order_by('id') + .values_list('id') + .iterator(chunk_size=100000) + ) + ) + + data_with_static_cache_query.update(storage_method="cache") + + updated_data_ids_filename = migration_name + "-data_ids.log" + with open(updated_data_ids_filename, "w") as data_ids_file: + print( + "The following Data ids have been switched from using \"filesystem\" chunk storage " + "to \"cache\":", + file=data_ids_file + ) + for data_id in data_with_static_cache_ids: + print(data_id, file=data_ids_file) + + common_logger.info( + "Information about migrated tasks is available in the migration log file: " + f"{updated_data_ids_filename}. You will need to remove data manually for these tasks." + ) + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0081_job_assignee_updated_date_and_more"), + ] + + operations = [ + migrations.RunPython(switch_tasks_with_static_chunks_to_dynamic_chunks) + ] From 51a7f83473b286e4ae5048e40b935b7d6ff87a50 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 15:28:20 +0300 Subject: [PATCH 038/227] Update downloading from cloud storage for packed data in task creation --- cvat/apps/engine/task.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index e021761b5b08..8701c9795379 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -686,14 +686,10 @@ def _update_status(msg: str) -> None: is_media_sorted = False if is_data_in_cloud: - # first we need to filter files and keep only supported ones - if any([v for k, v in media.items() if k != 'image']) and db_data.storage_method == models.StorageMethodChoice.CACHE: - # FUTURE-FIXME: This is a temporary workaround for creating tasks - # with unsupported cloud storage data (video, archive, pdf) when use_cache is enabled - db_data.storage_method = models.StorageMethodChoice.FILE_SYSTEM - _update_status("The 'use cache' option is ignored") - - if db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM or not settings.USE_CACHE: + # Packed media must be downloaded for task creation + if any(v for k, v in media.items() if k != 'image'): + _update_status("The input media is packed - downloading it for further processing") + filtered_data = [] for files in (i for i in media.values() if i): filtered_data.extend(files) @@ -708,9 +704,11 @@ def _update_status(msg: str) -> None: step = db_data.get_frame_step() if start_frame or step != 1 or stop_frame != len(filtered_data) - 1: media_to_download = filtered_data[start_frame : stop_frame + 1: step] + _download_data_from_cloud_storage(db_data.cloud_storage, media_to_download, upload_dir) del media_to_download del filtered_data + is_data_in_cloud = False db_data.storage = models.StorageChoice.LOCAL else: From 65e417495bf10c6992df0ad680f5db19765b1b8d Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 16:22:39 +0300 Subject: [PATCH 039/227] Update changelog --- changelog.d/20240812_161617_mzhiltso_job_chunks.md | 4 ++++ changelog.d/20240812_161734_mzhiltso_job_chunks.md | 4 ++++ changelog.d/20240812_161912_mzhiltso_job_chunks.md | 6 ++++++ 3 files changed, 14 insertions(+) create mode 100644 changelog.d/20240812_161617_mzhiltso_job_chunks.md create mode 100644 changelog.d/20240812_161734_mzhiltso_job_chunks.md create mode 100644 changelog.d/20240812_161912_mzhiltso_job_chunks.md diff --git a/changelog.d/20240812_161617_mzhiltso_job_chunks.md b/changelog.d/20240812_161617_mzhiltso_job_chunks.md new file mode 100644 index 000000000000..f78376d94438 --- /dev/null +++ b/changelog.d/20240812_161617_mzhiltso_job_chunks.md @@ -0,0 +1,4 @@ +### Added + +- A server setting to disable media chunks on the local filesystem + () diff --git a/changelog.d/20240812_161734_mzhiltso_job_chunks.md b/changelog.d/20240812_161734_mzhiltso_job_chunks.md new file mode 100644 index 000000000000..2a587593b4f5 --- /dev/null +++ b/changelog.d/20240812_161734_mzhiltso_job_chunks.md @@ -0,0 +1,4 @@ +### Changed + +- Jobs now have separate chunk ids starting from 0, instead of using ones from the task + () diff --git a/changelog.d/20240812_161912_mzhiltso_job_chunks.md b/changelog.d/20240812_161912_mzhiltso_job_chunks.md new file mode 100644 index 000000000000..2a0198dd8f78 --- /dev/null +++ b/changelog.d/20240812_161912_mzhiltso_job_chunks.md @@ -0,0 +1,6 @@ +### Fixed + +- Various memory leaks in video reading on the server + () +- Job assignees will not receive frames from adjacent jobs in the boundary chunks + () From 34f972fc01031f1457090581d91da4182490b1f8 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 16:24:17 +0300 Subject: [PATCH 040/227] Update migration name --- ...move_to_segment_chunks.py => 0083_move_to_segment_chunks.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename cvat/apps/engine/migrations/{0082_move_to_segment_chunks.py => 0083_move_to_segment_chunks.py} (96%) diff --git a/cvat/apps/engine/migrations/0082_move_to_segment_chunks.py b/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py similarity index 96% rename from cvat/apps/engine/migrations/0082_move_to_segment_chunks.py rename to cvat/apps/engine/migrations/0083_move_to_segment_chunks.py index 42ee41dd6572..d21a7f669b2b 100644 --- a/cvat/apps/engine/migrations/0082_move_to_segment_chunks.py +++ b/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py @@ -44,7 +44,7 @@ def switch_tasks_with_static_chunks_to_dynamic_chunks(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("engine", "0081_job_assignee_updated_date_and_more"), + ("engine", "0082_alter_labeledimage_job_and_more"), ] operations = [ From 2bb2b17d78bdcfc5f3b17f6e1b78c45269ea8b14 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 17:32:28 +0300 Subject: [PATCH 041/227] Polish some code --- cvat/apps/engine/cache.py | 12 ++++++------ cvat/apps/engine/frame_provider.py | 4 ++-- cvat/apps/engine/media_extractors.py | 16 ++++++++-------- cvat/apps/engine/task.py | 2 +- cvat/apps/engine/views.py | 2 +- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index be5c2d799e83..83cdecec13e4 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -15,7 +15,7 @@ from contextlib import ExitStack, closing from datetime import datetime, timezone from itertools import pairwise -from typing import Any, Callable, Iterator, Optional, Sequence, Tuple, Type, Union +from typing import Callable, Iterator, Optional, Sequence, Tuple, Type, Union import av import cv2 @@ -58,8 +58,6 @@ class MediaCache: def __init__(self) -> None: self._cache = caches["media"] - # TODO migrate keys (check if they will be removed) - def get_checksum(self, value: bytes) -> int: return zlib.crc32(value) @@ -180,10 +178,12 @@ def _read_raw_frames( ) if not os.path.isfile(manifest_path): try: - reader._manifest.link(source_path, force=True) - reader._manifest.create() + reader.manifest.link(source_path, force=True) + reader.manifest.create() except Exception as e: - # TODO: improve logging + slogger.task[db_task.id].warning( + f"Failed to create video manifest: {e}", exc_info=True + ) reader = None if reader: diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index ac764f00c2a4..1a0e713eef11 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -224,7 +224,7 @@ def validate_frame_number(self, frame_number: int) -> int: def validate_chunk_number(self, chunk_number: int) -> int: last_chunk = math.ceil(self._db_task.data.size / self._db_task.data.chunk_size) - 1 - if not (0 <= chunk_number <= last_chunk): + if not 0 <= chunk_number <= last_chunk: raise ValidationError( f"Invalid chunk number '{chunk_number}'. " f"The chunk number should be in the [0, {last_chunk}] range" @@ -463,7 +463,7 @@ def get_chunk_number(self, frame_number: int) -> int: def validate_chunk_number(self, chunk_number: int) -> int: segment_size = self._db_segment.frame_count last_chunk = math.ceil(segment_size / self._db_segment.task.data.chunk_size) - 1 - if not (0 <= chunk_number <= last_chunk): + if not 0 <= chunk_number <= last_chunk: raise ValidationError( f"Invalid chunk number '{chunk_number}'. " f"The chunk number should be in the [0, {last_chunk}] range" diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 3b843ea3c9cb..296023c8f93d 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -723,15 +723,15 @@ class VideoReaderWithManifest: # TODO: merge this class with VideoReader def __init__(self, manifest_path: str, source_path: str, *, allow_threading: bool = False): - self._source_path = source_path - self._manifest = VideoManifestManager(manifest_path) - if self._manifest.exists: - self._manifest.init_index() + self.source_path = source_path + self.manifest = VideoManifestManager(manifest_path) + if self.manifest.exists: + self.manifest.init_index() self.allow_threading = allow_threading def _read_av_container(self) -> ContextManager[av.container.InputContainer]: - return _AvVideoReading().read_av_container(self._source_path) + return _AvVideoReading().read_av_container(self.source_path) def _decode_stream( self, container: av.container.Container, video_stream: av.video.stream.VideoStream @@ -740,11 +740,11 @@ def _decode_stream( def _get_nearest_left_key_frame(self, frame_id: int) -> tuple[int, int]: nearest_left_keyframe_pos = bisect( - self._manifest, frame_id, key=lambda entry: entry.get('number') + self.manifest, frame_id, key=lambda entry: entry.get('number') ) if nearest_left_keyframe_pos: - frame_number = self._manifest[nearest_left_keyframe_pos - 1].get('number') - timestamp = self._manifest[nearest_left_keyframe_pos - 1].get('pts') + frame_number = self.manifest[nearest_left_keyframe_pos - 1].get('number') + timestamp = self.manifest[nearest_left_keyframe_pos - 1].get('pts') else: frame_number = 0 timestamp = 0 diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 8701c9795379..db093165b859 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -13,7 +13,7 @@ from contextlib import closing from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Sequence, Tuple, Union, Iterable +from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Sequence, Tuple, Union from urllib import parse as urlparse from urllib import request as urlrequest diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index d7448472c8ab..f0670b40bb24 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -60,7 +60,7 @@ from cvat.apps.dataset_manager.bindings import CvatImportError from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer from cvat.apps.engine.frame_provider import ( - IFrameProvider, TaskFrameProvider, JobFrameProvider, FrameQuality, FrameOutputType + IFrameProvider, TaskFrameProvider, JobFrameProvider, FrameQuality ) from cvat.apps.engine.filters import NonModelSimpleFilter, NonModelOrderingFilter, NonModelJsonLogicFilter from cvat.apps.engine.media_extractors import get_mime From 3788917573fdb820c6474960638370db836692b8 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 17:57:37 +0300 Subject: [PATCH 042/227] Fix frame retrieval by id --- cvat/apps/engine/cache.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 83cdecec13e4..ff92614763bc 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -244,10 +244,31 @@ def _read_raw_frames( yield from media else: + requested_frame_iter = iter(frame_ids) + next_requested_frame_id = next(requested_frame_iter, None) + if next_requested_frame_id is None: + return + + # TODO: find a way to use prefetched results, if provided + db_images = ( + db_data.images.order_by("frame") + .filter(frame__gte=frame_ids[0], frame__lte=frame_ids[-1]) + .values_list("frame", "path") + .all() + ) + media = [] - for image in sorted(db_data.images.all(), key=lambda image: image.frame): - source_path = os.path.join(raw_data_dir, image.path) - media.append((source_path, source_path, None)) + for frame_id, frame_path in db_images: + if frame_id == next_requested_frame_id: + source_path = os.path.join(raw_data_dir, frame_path) + media.append((source_path, source_path, None)) + + next_requested_frame_id = next(requested_frame_iter, None) + + if next_requested_frame_id is None: + break + + assert next_requested_frame_id is None if dimension == models.DimensionType.DIM_2D: media = preload_images(media) From f695ae1ef91d42ce480f7acef98e23dda4ed757a Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 17:58:43 +0300 Subject: [PATCH 043/227] Remove extra import --- cvat/apps/engine/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index f0670b40bb24..f5e3827e9198 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -66,7 +66,7 @@ from cvat.apps.engine.media_extractors import get_mime from cvat.apps.engine.permissions import AnnotationGuidePermission, get_iam_context from cvat.apps.engine.models import ( - ClientFile, Job, JobType, Label, SegmentType, Task, Project, Issue, Data, + ClientFile, Job, JobType, Label, Task, Project, Issue, Data, Comment, StorageMethodChoice, StorageChoice, CloudProviderChoice, Location, CloudStorage as CloudStorageModel, Asset, AnnotationGuide) From 14a9033baba979cbaad87bf9daad1a525ad8dc86 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 18:25:50 +0300 Subject: [PATCH 044/227] Fix frame access in gt jobs --- cvat/apps/engine/cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index ff92614763bc..9a5ae932bef8 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -365,7 +365,7 @@ def prepare_masked_range_segment_chunk( frame_bytes = None if frame_idx in frame_set: - frame_bytes = frame_provider.get_frame(frame_idx, quality=quality)[0] + frame_bytes = frame_provider.get_frame(frame_idx, quality=quality).data if frame_size is not None: # Decoded video frames can have different size, restore the original one From e8bebe9e9b5bddfde2cf8e4f9e17bc8fee5fd5e3 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 18:53:32 +0300 Subject: [PATCH 045/227] Fix frame access in export --- cvat/apps/dataset_manager/bindings.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 0ab7d9d6a65b..eb648ba2acca 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -1333,7 +1333,7 @@ def add_task(self, task, files): @attrs(frozen=True, auto_attribs=True) class ImageSource: - db_data: Data + db_task: Task is_video: bool = attrib(kw_only=True) class ImageProvider: @@ -1363,7 +1363,9 @@ def video_frame_loader(_): # some formats or transforms can require image data return self._frame_provider.get_frame(frame_index, quality=FrameQuality.ORIGINAL, - out_type=FrameOutputType.NUMPY_ARRAY).data + out_type=FrameOutputType.NUMPY_ARRAY + ).data + return dm.Image(data=video_frame_loader, **image_kwargs) else: def image_loader(_): @@ -1372,7 +1374,9 @@ def image_loader(_): # for images use encoded data to avoid recoding return self._frame_provider.get_frame(frame_index, quality=FrameQuality.ORIGINAL, - out_type=FrameOutputType.BUFFER).data.getvalue() + out_type=FrameOutputType.BUFFER + ).data.getvalue() + return dm.ByteImage(data=image_loader, **image_kwargs) def _load_source(self, source_id: int, source: ImageSource) -> None: @@ -1380,7 +1384,7 @@ def _load_source(self, source_id: int, source: ImageSource) -> None: return self._unload_source() - self._frame_provider = TaskFrameProvider(next(iter(source.db_data.tasks))) # TODO: refactor + self._frame_provider = TaskFrameProvider(source.db_task) self._current_source_id = source_id def _unload_source(self) -> None: @@ -1396,7 +1400,7 @@ def __init__(self, sources: Dict[int, ImageSource]) -> None: self._images_per_source = { source_id: { image.id: image - for image in source.db_data.images.prefetch_related('related_files') + for image in source.db_task.data.images.prefetch_related('related_files') } for source_id, source in sources.items() } @@ -1405,7 +1409,7 @@ def get_image_for_frame(self, source_id: int, frame_id: int, **image_kwargs): source = self._sources[source_id] point_cloud_path = osp.join( - source.db_data.get_upload_dirname(), image_kwargs['path'], + source.db_task.data.get_upload_dirname(), image_kwargs['path'], ) image = self._images_per_source[source_id][frame_id] @@ -1521,8 +1525,15 @@ def __init__( ext = TaskFrameProvider.VIDEO_FRAME_EXT if dimension == DimensionType.DIM_3D or include_images: + if isinstance(instance_data, TaskData): + db_task = instance_data.db_instance + elif isinstance(instance_data, JobData): + db_task = instance_data.db_instance.segment.task + else: + assert False + self._image_provider = IMAGE_PROVIDERS_BY_DIMENSION[dimension]( - {0: ImageSource(instance_data.db_data, is_video=is_video)} + {0: ImageSource(db_task, is_video=is_video)} ) for frame_data in instance_data.group_by_frame(include_empty=True): @@ -1604,7 +1615,7 @@ def __init__( if self._dimension == DimensionType.DIM_3D or include_images: self._image_provider = IMAGE_PROVIDERS_BY_DIMENSION[self._dimension]( { - task.id: ImageSource(task.data, is_video=task.mode == 'interpolation') + task.id: ImageSource(task, is_video=task.mode == 'interpolation') for task in project_data.tasks } ) From bbef52f1f270757505593407b9449956d2578532 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 12 Aug 2024 19:51:53 +0300 Subject: [PATCH 046/227] Fix frame iteration for frame step and excluded frames, fix export in cvat format --- cvat/apps/dataset_manager/bindings.py | 8 +++--- cvat/apps/dataset_manager/formats/cvat.py | 4 +-- cvat/apps/engine/frame_provider.py | 33 +++++++++++++++++++---- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index eb648ba2acca..ec717d205fe9 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -240,7 +240,7 @@ def start(self) -> int: @property def stop(self) -> int: - return len(self) + return max(0, len(self) - 1) def _get_queryset(self): raise NotImplementedError() @@ -376,7 +376,7 @@ def _export_tag(self, tag): def _export_track(self, track, idx): track['shapes'] = list(filter(lambda x: not self._is_frame_deleted(x['frame']), track['shapes'])) tracked_shapes = TrackManager.get_interpolated_shapes( - track, 0, self.stop, self._annotation_ir.dimension) + track, 0, self.stop + 1, self._annotation_ir.dimension) for tracked_shape in tracked_shapes: tracked_shape["attributes"] += track["attributes"] tracked_shape["track_id"] = track["track_id"] if self._use_server_track_ids else idx @@ -432,7 +432,7 @@ def get_frame(idx): anno_manager = AnnotationManager(self._annotation_ir) for shape in sorted( - anno_manager.to_shapes(self.stop, self._annotation_ir.dimension, + anno_manager.to_shapes(self.stop + 1, self._annotation_ir.dimension, # Skip outside, deleted and excluded frames included_frames=included_frames, include_outside=False, @@ -763,7 +763,7 @@ def start(self) -> int: @property def stop(self) -> int: segment = self._db_job.segment - return segment.stop_frame + 1 + return segment.stop_frame @property def db_instance(self): diff --git a/cvat/apps/dataset_manager/formats/cvat.py b/cvat/apps/dataset_manager/formats/cvat.py index 4651fd398451..299982f2dcf7 100644 --- a/cvat/apps/dataset_manager/formats/cvat.py +++ b/cvat/apps/dataset_manager/formats/cvat.py @@ -1378,8 +1378,8 @@ def dump_media_files(instance_data: CommonData, img_dir: str, project_data: Proj ext = frame_provider.VIDEO_FRAME_EXT frames = frame_provider.iterate_frames( - start_frame=instance_data.start, - stop_frame=instance_data.stop, + start_frame=instance_data.abs_frame_id(instance_data.start), + stop_frame=instance_data.abs_frame_id(instance_data.stop), quality=FrameQuality.ORIGINAL, out_type=FrameOutputType.BUFFER, ) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 1a0e713eef11..c25c961ce104 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -6,6 +6,7 @@ from __future__ import annotations import io +import itertools import math from abc import ABCMeta, abstractmethod from dataclasses import dataclass @@ -360,9 +361,25 @@ def iterate_frames( quality: FrameQuality = FrameQuality.ORIGINAL, out_type: FrameOutputType = FrameOutputType.BUFFER, ) -> Iterator[DataWithMeta[AnyFrame]]: - # TODO: optimize segment access - for idx in range(start_frame, (stop_frame + 1) if stop_frame else None): - yield self.get_frame(idx, quality=quality, out_type=out_type) + frame_range = itertools.count(start_frame, self._db_task.data.get_frame_step()) + if stop_frame: + frame_range = itertools.takewhile(lambda x: x <= stop_frame, frame_range) + + db_segment = None + db_segment_frame_set = None + db_segment_frame_provider = None + for idx in frame_range: + if db_segment and idx not in db_segment_frame_set: + db_segment = None + db_segment_frame_set = None + db_segment_frame_provider = None + + if not db_segment: + db_segment = self._get_segment(idx) + db_segment_frame_set = set(db_segment.frame_set) + db_segment_frame_provider = SegmentFrameProvider(db_segment) + + yield db_segment_frame_provider.get_frame(idx, quality=quality, out_type=out_type) def _get_segment(self, validated_frame_number: int) -> models.Segment: if not self._db_task.data or not self._db_task.data.size: @@ -537,8 +554,14 @@ def iterate_frames( quality: FrameQuality = FrameQuality.ORIGINAL, out_type: FrameOutputType = FrameOutputType.BUFFER, ) -> Iterator[DataWithMeta[AnyFrame]]: - for idx in range(start_frame, (stop_frame + 1) if stop_frame else None): - yield self.get_frame(idx, quality=quality, out_type=out_type) + frame_range = itertools.count(start_frame, self._db_segment.task.data.get_frame_step()) + if stop_frame: + frame_range = itertools.takewhile(lambda x: x <= stop_frame, frame_range) + + segment_frame_set = set(self._db_segment.frame_set) + for idx in frame_range: + if idx in segment_frame_set: + yield self.get_frame(idx, quality=quality, out_type=out_type) class JobFrameProvider(SegmentFrameProvider): From 3d5bb5203d76ec8f0b6292e19d1abcd3b5209b74 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 13 Aug 2024 11:57:36 +0300 Subject: [PATCH 047/227] Remove unused import --- cvat/apps/dataset_manager/bindings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index ec717d205fe9..f8dd470b64a5 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -32,7 +32,7 @@ from cvat.apps.dataset_manager.formats.utils import get_label_color from cvat.apps.dataset_manager.util import add_prefetch_fields from cvat.apps.engine.frame_provider import TaskFrameProvider, FrameQuality, FrameOutputType -from cvat.apps.engine.models import (AttributeSpec, AttributeType, Data, DimensionType, Job, +from cvat.apps.engine.models import (AttributeSpec, AttributeType, DimensionType, Job, JobType, Label, LabelType, Project, SegmentType, ShapeType, Task) from cvat.apps.engine.rq_job_handler import RQJobMetaField From 0e9c5c8b391a7f89e6eca127aa69f381a046147d Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 13 Aug 2024 12:05:16 +0300 Subject: [PATCH 048/227] Fix error check in test --- tests/python/rest_api/test_jobs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/rest_api/test_jobs.py b/tests/python/rest_api/test_jobs.py index cd3445063367..f873e862a6a1 100644 --- a/tests/python/rest_api/test_jobs.py +++ b/tests/python/rest_api/test_jobs.py @@ -823,7 +823,7 @@ def test_can_get_gt_job_frame(self, admin_user, tasks, jobs, task_mode, quality, _check_status=False, ) assert response.status == HTTPStatus.BAD_REQUEST - assert b"The frame number doesn't belong to the job" in response.data + assert b"Incorrect requested frame number" in response.data (_, response) = api_client.jobs_api.retrieve_data( gt_job.id, number=included_frames[0], quality=quality, type="frame" From 351bdc87bbf4d8eb0f41abecb309f39723d5176b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 13 Aug 2024 12:05:29 +0300 Subject: [PATCH 049/227] Fix cleanup in test --- tests/python/rest_api/test_jobs.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/python/rest_api/test_jobs.py b/tests/python/rest_api/test_jobs.py index f873e862a6a1..3f49e6a35b61 100644 --- a/tests/python/rest_api/test_jobs.py +++ b/tests/python/rest_api/test_jobs.py @@ -629,12 +629,11 @@ def test_can_get_gt_job_meta(self, admin_user, tasks, jobs, task_mode, request): :job_frame_count ] gt_job = self._create_gt_job(admin_user, task_id, job_frame_ids) + request.addfinalizer(lambda: self._delete_gt_job(user, gt_job.id)) with make_api_client(user) as api_client: (gt_job_meta, _) = api_client.jobs_api.retrieve_data_meta(gt_job.id) - request.addfinalizer(lambda: self._delete_gt_job(user, gt_job.id)) - # These values are relative to the resulting task frames, unlike meta values assert 0 == gt_job.start_frame assert task_meta.size - 1 == gt_job.stop_frame @@ -684,12 +683,11 @@ def test_can_get_gt_job_meta_with_complex_frame_setup(self, admin_user, request) task_frame_ids = range(start_frame, stop_frame, frame_step) job_frame_ids = list(task_frame_ids[::3]) gt_job = self._create_gt_job(admin_user, task_id, job_frame_ids) + request.addfinalizer(lambda: self._delete_gt_job(admin_user, gt_job.id)) with make_api_client(admin_user) as api_client: (gt_job_meta, _) = api_client.jobs_api.retrieve_data_meta(gt_job.id) - request.addfinalizer(lambda: self._delete_gt_job(admin_user, gt_job.id)) - # These values are relative to the resulting task frames, unlike meta values assert 0 == gt_job.start_frame assert len(task_frame_ids) - 1 == gt_job.stop_frame @@ -731,6 +729,7 @@ def test_can_get_gt_job_chunk(self, admin_user, tasks, jobs, task_mode, quality, :job_frame_count ] gt_job = self._create_gt_job(admin_user, task_id, job_frame_ids) + request.addfinalizer(lambda: self._delete_gt_job(admin_user, gt_job.id)) with make_api_client(admin_user) as api_client: (chunk_file, response) = api_client.jobs_api.retrieve_data( @@ -738,8 +737,6 @@ def test_can_get_gt_job_chunk(self, admin_user, tasks, jobs, task_mode, quality, ) assert response.status == HTTPStatus.OK - request.addfinalizer(lambda: self._delete_gt_job(admin_user, gt_job.id)) - frame_range = range( task_meta.start_frame, min(task_meta.stop_frame + 1, task_meta.chunk_size), frame_step ) @@ -806,6 +803,7 @@ def test_can_get_gt_job_frame(self, admin_user, tasks, jobs, task_mode, quality, :job_frame_count ] gt_job = self._create_gt_job(admin_user, task_id, job_frame_ids) + request.addfinalizer(lambda: self._delete_gt_job(admin_user, gt_job.id)) frame_range = range( task_meta.start_frame, min(task_meta.stop_frame + 1, task_meta.chunk_size), frame_step @@ -830,8 +828,6 @@ def test_can_get_gt_job_frame(self, admin_user, tasks, jobs, task_mode, quality, ) assert response.status == HTTPStatus.OK - request.addfinalizer(lambda: self._delete_gt_job(admin_user, gt_job.id)) - @pytest.mark.usefixtures("restore_db_per_class") class TestListJobs: From a71852c5f8d777f8dca1942d3803d2ca2be5f27c Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 13 Aug 2024 16:35:32 +0300 Subject: [PATCH 050/227] Add handling for disabled static cache during task creation --- cvat/apps/engine/task.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index db093165b859..c233aa47fa06 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -569,6 +569,12 @@ def _update_status(msg: str) -> None: else: assert False, f"Unknown file storage {db_data.storage}" + if ( + db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM and + not settings.MEDIA_CACHE_ALLOW_STATIC_CACHE + ): + db_data.storage_method = models.StorageMethodChoice.CACHE + manifest_file = _validate_manifest( manifest_files, manifest_root, From d90ca0df014c95c750ee8903c56307ac165b4bf5 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 13 Aug 2024 16:35:48 +0300 Subject: [PATCH 051/227] Refactor some code --- cvat/apps/engine/task.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index c233aa47fa06..efe4e892292a 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -540,18 +540,18 @@ def _create_thread( slogger.glob.info("create task #{}".format(db_task.id)) - job_file_mapping = _validate_job_file_mapping(db_task, data) - - db_data = db_task.data - upload_dir = db_data.get_upload_dirname() if db_data.storage != models.StorageChoice.SHARE else settings.SHARE_ROOT - is_data_in_cloud = db_data.storage == models.StorageChoice.CLOUD_STORAGE - job = rq.get_current_job() def _update_status(msg: str) -> None: job.meta['status'] = msg job.save_meta() + job_file_mapping = _validate_job_file_mapping(db_task, data) + + db_data = db_task.data + upload_dir = db_data.get_upload_dirname() if db_data.storage != models.StorageChoice.SHARE else settings.SHARE_ROOT + is_data_in_cloud = db_data.storage == models.StorageChoice.CLOUD_STORAGE + if data['remote_files'] and not isDatasetImport: data['remote_files'] = _download_data(data['remote_files'], upload_dir) From 03e749a07a46dbe031e3f5b15ce22d1a8deb4a69 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 13 Aug 2024 16:36:30 +0300 Subject: [PATCH 052/227] Fix downloading for cloud data in task creation --- cvat/apps/engine/task.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index efe4e892292a..31eb2db3c652 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -692,9 +692,15 @@ def _update_status(msg: str) -> None: is_media_sorted = False if is_data_in_cloud: - # Packed media must be downloaded for task creation - if any(v for k, v in media.items() if k != 'image'): - _update_status("The input media is packed - downloading it for further processing") + if ( + # Download remote data if local storage is requested + # TODO: maybe move into cache building to fail faster on invalid task configurations + db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM or + + # Packed media must be downloaded for task creation + any(v for k, v in media.items() if k != 'image') + ): + _update_status("Downloading input media") filtered_data = [] for files in (i for i in media.values() if i): From c0822a0020212edb18e6a2019ff80decd45b4c25 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 13 Aug 2024 18:17:54 +0300 Subject: [PATCH 053/227] Fix preview reading for projects --- cvat/apps/engine/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index f5e3827e9198..9dd42dad9c68 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -647,7 +647,7 @@ def preview(self, request, pk): data_quality='compressed', ) - return data_getter(request) + return data_getter() @staticmethod def _get_rq_response(queue, job_id): From 56d413fc939e60874737dee1e5583aa2fb97a095 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 13 Aug 2024 19:24:20 +0300 Subject: [PATCH 054/227] Fix failing sdk tests --- cvat/apps/engine/frame_provider.py | 4 ++-- cvat/apps/engine/media_extractors.py | 29 +++++++++++++++--------- tests/python/sdk/test_auto_annotation.py | 1 + tests/python/sdk/test_datasets.py | 1 + tests/python/sdk/test_jobs.py | 1 + tests/python/sdk/test_projects.py | 1 + tests/python/sdk/test_tasks.py | 1 + 7 files changed, 25 insertions(+), 13 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index c25c961ce104..6a6ea1c82c72 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -314,10 +314,10 @@ def get_chunk( ): continue - frame, _, _ = segment_frame_provider._get_raw_frame( + frame, frame_name, _ = segment_frame_provider._get_raw_frame( task_chunk_frame_id, quality=quality ) - task_chunk_frames[task_chunk_frame_id] = (frame, None, None) + task_chunk_frames[task_chunk_frame_id] = (frame, frame_name, None) writer_kwargs = {} if self._db_task.dimension == models.DimensionType.DIM_3D: diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 296023c8f93d..0fb3f53658c0 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -92,13 +92,13 @@ def sort(images, sorting_method=SortingMethod.LEXICOGRAPHICAL, func=None): else: raise NotImplementedError() -def image_size_within_orientation(img: Image): +def image_size_within_orientation(img: Image.Image): orientation = img.getexif().get(ORIENTATION_EXIF_TAG, ORIENTATION.NORMAL_HORIZONTAL) if orientation > 4: return img.height, img.width return img.width, img.height -def has_exif_rotation(img: Image): +def has_exif_rotation(img: Image.Image): return img.getexif().get(ORIENTATION_EXIF_TAG, ORIENTATION.NORMAL_HORIZONTAL) != ORIENTATION.NORMAL_HORIZONTAL _T = TypeVar("_T") @@ -851,33 +851,37 @@ class ZipChunkWriter(IChunkWriter): POINT_CLOUD_EXT = 'pcd' def _write_pcd_file(self, image: str|io.BytesIO) -> tuple[io.BytesIO, str, int, int]: - image_buf = open(image, "rb") if isinstance(image, str) else image - try: + with ExitStack() as es: + if isinstance(image, str): + image_buf = es.enter_context(open(image, "rb")) + else: + image_buf = image + properties = ValidateDimension.get_pcd_properties(image_buf) w, h = int(properties["WIDTH"]), int(properties["HEIGHT"]) image_buf.seek(0, 0) return io.BytesIO(image_buf.read()), self.POINT_CLOUD_EXT, w, h - finally: - if isinstance(image, str): - image_buf.close() def save_as_chunk(self, images: Iterator[tuple[Image.Image|io.IOBase|str, str, str]], chunk_path: str): with zipfile.ZipFile(chunk_path, 'x') as zip_chunk: for idx, (image, path, _) in enumerate(images): ext = os.path.splitext(path)[1].replace('.', '') - output = io.BytesIO() + if self._dimension == DimensionType.DIM_2D: # current version of Pillow applies exif rotation immediately when TIFF image opened # and it removes rotation tag after that # so, has_exif_rotation(image) will return False for TIFF images even if they were actually rotated # and original files will be added to the archive (without applied rotation) # that is why we need the second part of the condition - if has_exif_rotation(image) or image.format == 'TIFF': + if isinstance(image, Image.Image) and ( + has_exif_rotation(image) or image.format == 'TIFF' + ): + output = io.BytesIO() rot_image = ImageOps.exif_transpose(image) try: if image.format == 'TIFF': # https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html - # use loseless lzw compression for tiff images + # use lossless lzw compression for tiff images rot_image.save(output, format='TIFF', compression='tiff_lzw') else: rot_image.save( @@ -889,16 +893,19 @@ def save_as_chunk(self, images: Iterator[tuple[Image.Image|io.IOBase|str, str, s ) finally: rot_image.close() + elif isinstance(image, io.IOBase): + output = image else: output = path else: output, ext = self._write_pcd_file(path)[0:2] - arcname = '{:06d}.{}'.format(idx, ext) + arcname = '{:06d}.{}'.format(idx, ext) if isinstance(output, io.BytesIO): zip_chunk.writestr(arcname, output.getvalue()) else: zip_chunk.write(filename=output, arcname=arcname) + # return empty list because ZipChunkWriter write files as is # and does not decode it to know img size. return [] diff --git a/tests/python/sdk/test_auto_annotation.py b/tests/python/sdk/test_auto_annotation.py index 142c4354c4d1..e7ac8418b69a 100644 --- a/tests/python/sdk/test_auto_annotation.py +++ b/tests/python/sdk/test_auto_annotation.py @@ -29,6 +29,7 @@ def _common_setup( tmp_path: Path, fxt_login: Tuple[Client, str], fxt_logger: Tuple[Logger, io.StringIO], + restore_redis_ondisk_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 d5fbc0957eb7..542ad9a1e80c 100644 --- a/tests/python/sdk/test_datasets.py +++ b/tests/python/sdk/test_datasets.py @@ -23,6 +23,7 @@ def _common_setup( tmp_path: Path, fxt_login: Tuple[Client, str], fxt_logger: Tuple[Logger, io.StringIO], + restore_redis_ondisk_per_function, ): logger = fxt_logger[0] client = fxt_login[0] diff --git a/tests/python/sdk/test_jobs.py b/tests/python/sdk/test_jobs.py index a3e3b9d8516c..f73b9cae7afa 100644 --- a/tests/python/sdk/test_jobs.py +++ b/tests/python/sdk/test_jobs.py @@ -24,6 +24,7 @@ def setup( fxt_login: Tuple[Client, str], fxt_logger: Tuple[Logger, io.StringIO], fxt_stdout: io.StringIO, + restore_redis_ondisk_per_function, ): self.tmp_path = tmp_path logger, self.logger_stream = fxt_logger diff --git a/tests/python/sdk/test_projects.py b/tests/python/sdk/test_projects.py index 852a286fc288..09592dddbacc 100644 --- a/tests/python/sdk/test_projects.py +++ b/tests/python/sdk/test_projects.py @@ -26,6 +26,7 @@ def setup( fxt_login: Tuple[Client, str], fxt_logger: Tuple[Logger, io.StringIO], fxt_stdout: io.StringIO, + restore_redis_ondisk_per_function, ): self.tmp_path = tmp_path logger, self.logger_stream = fxt_logger diff --git a/tests/python/sdk/test_tasks.py b/tests/python/sdk/test_tasks.py index f97e88a29248..3cdbf69f75ee 100644 --- a/tests/python/sdk/test_tasks.py +++ b/tests/python/sdk/test_tasks.py @@ -29,6 +29,7 @@ def setup( fxt_login: Tuple[Client, str], fxt_logger: Tuple[Logger, io.StringIO], fxt_stdout: io.StringIO, + restore_redis_ondisk_per_function, ): self.tmp_path = tmp_path logger, self.logger_stream = fxt_logger From 48f479413cd77b3e37aeff105eaa6af4ac1ba440 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 13 Aug 2024 20:44:29 +0300 Subject: [PATCH 055/227] Fix other failing sdk tests --- tests/python/sdk/test_pytorch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/python/sdk/test_pytorch.py b/tests/python/sdk/test_pytorch.py index 722cb37ab003..2bcbd122abff 100644 --- a/tests/python/sdk/test_pytorch.py +++ b/tests/python/sdk/test_pytorch.py @@ -36,6 +36,7 @@ def _common_setup( tmp_path: Path, fxt_login: Tuple[Client, str], fxt_logger: Tuple[Logger, io.StringIO], + restore_redis_ondisk_per_function, ): logger = fxt_logger[0] client = fxt_login[0] From 5c0cc1ae10ca5385faec1fe2cbe9a3afc95fdff9 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 14 Aug 2024 18:13:33 +0300 Subject: [PATCH 056/227] Improve logging for migration --- cvat/apps/engine/log.py | 39 +++++++++++-------- .../migrations/0083_move_to_segment_chunks.py | 11 ++++-- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/cvat/apps/engine/log.py b/cvat/apps/engine/log.py index 5f123d33eef8..6f1740e74fd4 100644 --- a/cvat/apps/engine/log.py +++ b/cvat/apps/engine/log.py @@ -59,24 +59,31 @@ def get_logger(logger_name, log_file): vlogger = logging.getLogger('vector') + +def get_migration_log_dir() -> str: + return settings.MIGRATIONS_LOGS_ROOT + +def get_migration_log_file_path(migration_name: str) -> str: + return osp.join(get_migration_log_dir(), f'{migration_name}.log') + @contextmanager def get_migration_logger(migration_name): - migration_log_file = '{}.log'.format(migration_name) + migration_log_file_path = get_migration_log_file_path(migration_name) stdout = sys.stdout stderr = sys.stderr + # redirect all stdout to the file - log_file_object = open(osp.join(settings.MIGRATIONS_LOGS_ROOT, migration_log_file), 'w') - sys.stdout = log_file_object - sys.stderr = log_file_object - - log = logging.getLogger(migration_name) - log.addHandler(logging.StreamHandler(stdout)) - log.addHandler(logging.StreamHandler(log_file_object)) - log.setLevel(logging.INFO) - - try: - yield log - finally: - log_file_object.close() - sys.stdout = stdout - sys.stderr = stderr + with open(migration_log_file_path, 'w') as log_file_object: + sys.stdout = log_file_object + sys.stderr = log_file_object + + log = logging.getLogger(migration_name) + log.addHandler(logging.StreamHandler(stdout)) + log.addHandler(logging.StreamHandler(log_file_object)) + log.setLevel(logging.INFO) + + try: + yield log + finally: + sys.stdout = stdout + sys.stderr = stderr diff --git a/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py b/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py index d21a7f669b2b..c9f59593d23b 100644 --- a/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py +++ b/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py @@ -2,10 +2,11 @@ import os from django.db import migrations -from cvat.apps.engine.log import get_migration_logger +from cvat.apps.engine.log import get_migration_logger, get_migration_log_dir def switch_tasks_with_static_chunks_to_dynamic_chunks(apps, schema_editor): migration_name = os.path.splitext(os.path.basename(__file__))[0] + migration_log_dir = get_migration_log_dir() with get_migration_logger(migration_name) as common_logger: Data = apps.get_model("engine", "Data") @@ -26,8 +27,8 @@ def switch_tasks_with_static_chunks_to_dynamic_chunks(apps, schema_editor): data_with_static_cache_query.update(storage_method="cache") - updated_data_ids_filename = migration_name + "-data_ids.log" - with open(updated_data_ids_filename, "w") as data_ids_file: + updated_ids_filename = os.path.join(migration_log_dir, migration_name + "-data_ids.log") + with open(updated_ids_filename, "w") as data_ids_file: print( "The following Data ids have been switched from using \"filesystem\" chunk storage " "to \"cache\":", @@ -38,7 +39,9 @@ def switch_tasks_with_static_chunks_to_dynamic_chunks(apps, schema_editor): common_logger.info( "Information about migrated tasks is available in the migration log file: " - f"{updated_data_ids_filename}. You will need to remove data manually for these tasks." + "{}. You will need to remove data manually for these tasks.".format( + updated_ids_filename + ) ) class Migration(migrations.Migration): From 5abd89137cd133ccf27332b66b1054a3f6e2dc2f Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 14 Aug 2024 18:55:40 +0300 Subject: [PATCH 057/227] Fix invalid starting index --- cvat/apps/engine/media_extractors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 0fb3f53658c0..40fae1176a9f 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -769,7 +769,7 @@ def iterate_frames(self, *, frame_filter: Iterable[int]) -> Iterable[av.VideoFra container.seek(offset=start_decode_timestamp, stream=video_stream) - frame_counter = itertools.count(start_decode_frame_number - 1) + frame_counter = itertools.count(start_decode_frame_number) with closing(self._decode_stream(container, video_stream)) as stream_decoder: for frame, frame_number in zip(stream_decoder, frame_counter): if frame_number == next_frame_filter_frame: From 749b970d206352e4fe75210f1549f5c61701a3b5 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 14 Aug 2024 18:55:56 +0300 Subject: [PATCH 058/227] Fix frame reading in lambda functions --- cvat/apps/lambda_manager/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/lambda_manager/views.py b/cvat/apps/lambda_manager/views.py index 776911eac3f6..fdb8aaa869ef 100644 --- a/cvat/apps/lambda_manager/views.py +++ b/cvat/apps/lambda_manager/views.py @@ -499,7 +499,7 @@ def _get_image(self, db_task, frame, quality): frame_provider = TaskFrameProvider(db_task) image = frame_provider.get_frame(frame, quality=quality) - return base64.b64encode(image[0].getvalue()).decode('utf-8') + return base64.b64encode(image.data.getvalue()).decode('utf-8') class LambdaQueue: RESULT_TTL = timedelta(minutes=30) From 9105cd3b3d9869803a67b2be36dc38b57c75242f Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 14 Aug 2024 19:58:08 +0300 Subject: [PATCH 059/227] Fix unintended frame indexing changes --- cvat/apps/dataset_manager/formats/cvat.py | 4 +-- cvat/apps/engine/cache.py | 7 +++-- cvat/apps/engine/frame_provider.py | 38 +++++++++++++++++------ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/cvat/apps/dataset_manager/formats/cvat.py b/cvat/apps/dataset_manager/formats/cvat.py index 299982f2dcf7..4651fd398451 100644 --- a/cvat/apps/dataset_manager/formats/cvat.py +++ b/cvat/apps/dataset_manager/formats/cvat.py @@ -1378,8 +1378,8 @@ def dump_media_files(instance_data: CommonData, img_dir: str, project_data: Proj ext = frame_provider.VIDEO_FRAME_EXT frames = frame_provider.iterate_frames( - start_frame=instance_data.abs_frame_id(instance_data.start), - stop_frame=instance_data.abs_frame_id(instance_data.stop), + start_frame=instance_data.start, + stop_frame=instance_data.stop, quality=FrameQuality.ORIGINAL, out_type=FrameOutputType.BUFFER, ) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 9a5ae932bef8..d7af8b861535 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -404,11 +404,14 @@ def _prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: os.path.join(os.path.dirname(__file__), "assets/3d_preview.jpeg") ) else: - from cvat.apps.engine.frame_provider import FrameOutputType, SegmentFrameProvider + from cvat.apps.engine.frame_provider import ( + FrameOutputType, SegmentFrameProvider, TaskFrameProvider + ) + task_frame_provider = TaskFrameProvider(db_segment.task) segment_frame_provider = SegmentFrameProvider(db_segment) preview = segment_frame_provider.get_frame( - min(db_segment.frame_set), + task_frame_provider.get_rel_frame_number(min(db_segment.frame_set)), quality=FrameQuality.COMPRESSED, out_type=FrameOutputType.PIL, ).data diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 6a6ea1c82c72..8ce5be00f5a2 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -207,18 +207,22 @@ def iterate_frames( out_type: FrameOutputType = FrameOutputType.BUFFER, ) -> Iterator[DataWithMeta[AnyFrame]]: ... + def _get_abs_frame_number(self, db_data: models.Data, rel_frame_number: int) -> int: + return db_data.start_frame + rel_frame_number * db_data.get_frame_step() + + def _get_rel_frame_number(self, db_data: models.Data, abs_frame_number: int) -> int: + return (abs_frame_number - db_data.start_frame) // db_data.get_frame_step() + class TaskFrameProvider(IFrameProvider): def __init__(self, db_task: models.Task) -> None: self._db_task = db_task def validate_frame_number(self, frame_number: int) -> int: - start = self._db_task.data.start_frame - stop = self._db_task.data.stop_frame - if frame_number not in range(start, stop + 1, self._db_task.data.get_frame_step()): + if frame_number not in range(0, self._db_task.data.size): raise ValidationError( f"Invalid frame '{frame_number}'. " - f"The frame number should be in the [{start}, {stop}] range" + f"The frame number should be in the [0, {self._db_task.data.size}] range" ) return frame_number @@ -236,6 +240,17 @@ def validate_chunk_number(self, chunk_number: int) -> int: def get_chunk_number(self, frame_number: int) -> int: return int(frame_number) // self._db_task.data.chunk_size + def get_abs_frame_number(self, rel_frame_number: int) -> int: + "Returns absolute frame number in the task (in the range [start, stop, step])" + return super()._get_abs_frame_number(self._db_task.data, rel_frame_number) + + def get_rel_frame_number(self, abs_frame_number: int) -> int: + """ + Returns relative frame number in the task (in the range [0, task_size - 1]). + This is the "normal" frame number, expected in other methods. + """ + return super()._get_rel_frame_number(self._db_task.data, abs_frame_number) + def get_preview(self) -> DataWithMeta[BytesIO]: return self._get_segment_frame_provider(self._db_task.data.start_frame).get_preview() @@ -315,7 +330,7 @@ def get_chunk( continue frame, frame_name, _ = segment_frame_provider._get_raw_frame( - task_chunk_frame_id, quality=quality + self.get_rel_frame_number(task_chunk_frame_id), quality=quality ) task_chunk_frames[task_chunk_frame_id] = (frame, frame_name, None) @@ -385,11 +400,13 @@ def _get_segment(self, validated_frame_number: int) -> models.Segment: if not self._db_task.data or not self._db_task.data.size: raise ValidationError("Task has no data") + abs_frame_number = self.get_abs_frame_number(validated_frame_number) + return next( s for s in self._db_task.segment_set.all() if s.type == models.SegmentType.RANGE - if validated_frame_number in s.frame_set + if abs_frame_number in s.frame_set ) def _get_segment_frame_provider(self, frame_number: int) -> SegmentFrameProvider: @@ -465,12 +482,13 @@ def __len__(self): def validate_frame_number(self, frame_number: int) -> Tuple[int, int, int]: frame_sequence = list(self._db_segment.frame_set) - if frame_number not in frame_sequence: + abs_frame_number = self._get_abs_frame_number(self._db_segment.task.data, frame_number) + if abs_frame_number not in frame_sequence: raise ValidationError(f"Incorrect requested frame number: {frame_number}") # TODO: maybe optimize search chunk_number, frame_position = divmod( - frame_sequence.index(frame_number), self._db_segment.task.data.chunk_size + frame_sequence.index(abs_frame_number), self._db_segment.task.data.chunk_size ) return frame_number, chunk_number, frame_position @@ -554,13 +572,13 @@ def iterate_frames( quality: FrameQuality = FrameQuality.ORIGINAL, out_type: FrameOutputType = FrameOutputType.BUFFER, ) -> Iterator[DataWithMeta[AnyFrame]]: - frame_range = itertools.count(start_frame, self._db_segment.task.data.get_frame_step()) + frame_range = itertools.count(start_frame) if stop_frame: frame_range = itertools.takewhile(lambda x: x <= stop_frame, frame_range) segment_frame_set = set(self._db_segment.frame_set) for idx in frame_range: - if idx in segment_frame_set: + if self._get_abs_frame_number(self._db_segment.task.data, idx) in segment_frame_set: yield self.get_frame(idx, quality=quality, out_type=out_type) From 8dafcbe4ebf41054a2c676bba43511b170cddb76 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 14 Aug 2024 20:46:58 +0300 Subject: [PATCH 060/227] Fix various indexing errors in media extractors --- cvat/apps/engine/media_extractors.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 40fae1176a9f..17c815695047 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -250,7 +250,7 @@ def __len__(self): @property def frame_range(self): - return range(self._start, self._stop, self._step) + return range(self._start, self._stop + 1, self._step) class ImageListReader(IMediaReader): def __init__(self, @@ -264,9 +264,9 @@ def __init__(self, raise Exception('No image found') if not stop: - stop = len(source_path) + stop = max(0, len(source_path) - 1) else: - stop = min(len(source_path), stop + 1) + stop = max(0, min(len(source_path), stop + 1) - 1) step = max(step, 1) assert stop > start @@ -281,7 +281,7 @@ def __init__(self, self._sorting_method = sorting_method def __iter__(self): - for i in range(self._start, self._stop, self._step): + for i in range(self._start, self._stop + 1, self._step): yield (self.get_image(i), self.get_path(i), i) def __contains__(self, media_file): @@ -294,7 +294,7 @@ def filter(self, callback): source_path, step=self._step, start=self._start, - stop=self._stop - 1, + stop=self._stop, dimension=self._dimension, sorting_method=self._sorting_method ) @@ -306,7 +306,7 @@ def get_image(self, i): return self._source_path[i] def get_progress(self, pos): - return (pos - self._start + 1) / (self._stop - self._start) + return (pos + 1) / (len(self.frame_range) or 1) def get_preview(self, frame): if self._dimension == DimensionType.DIM_3D: @@ -560,7 +560,7 @@ def __init__( source_path=source_path, step=step, start=start, - stop=stop + 1 if stop is not None else stop, + stop=stop if stop is not None else stop, dimension=dimension, ) From 4cbf82f2d8315b389c0ca63beb8ad48488a8897a Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 14 Aug 2024 20:47:29 +0300 Subject: [PATCH 061/227] Fix temp resource cleanup in server tests --- cvat/apps/engine/tests/utils.py | 18 ++++----- cvat/apps/lambda_manager/tests/test_lambda.py | 38 +++---------------- 2 files changed, 14 insertions(+), 42 deletions(-) diff --git a/cvat/apps/engine/tests/utils.py b/cvat/apps/engine/tests/utils.py index b884b3e9b4c4..87c5911e12b0 100644 --- a/cvat/apps/engine/tests/utils.py +++ b/cvat/apps/engine/tests/utils.py @@ -92,14 +92,7 @@ def clear_rq_jobs(): class ApiTestBase(APITestCase): - def _clear_rq_jobs(self): - clear_rq_jobs() - - def setUp(self): - super().setUp() - self.client = APIClient() - - def tearDown(self): + def _clear_temp_data(self): # Clear server frame/chunk cache. # The parent class clears DB changes, and it can lead to under-cleaned task data, # which can affect other tests. @@ -112,7 +105,14 @@ def tearDown(self): # Clear any remaining RQ jobs produced by the tests executed self._clear_rq_jobs() - return super().tearDown() + def _clear_rq_jobs(self): + clear_rq_jobs() + + def setUp(self): + self._clear_temp_data() + + super().setUp() + self.client = APIClient() def generate_image_file(filename, size=(100, 100)): diff --git a/cvat/apps/lambda_manager/tests/test_lambda.py b/cvat/apps/lambda_manager/tests/test_lambda.py index e360ab1996d8..51973fdbd4a0 100644 --- a/cvat/apps/lambda_manager/tests/test_lambda.py +++ b/cvat/apps/lambda_manager/tests/test_lambda.py @@ -1,11 +1,10 @@ # Copyright (C) 2021-2022 Intel Corporation -# Copyright (C) 2023 CVAT.ai Corporation +# Copyright (C) 2023-2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT from collections import OrderedDict from itertools import groupby -from io import BytesIO from typing import Dict, Optional from unittest import mock, skip import json @@ -14,11 +13,11 @@ import requests from django.contrib.auth.models import Group, User from django.http import HttpResponseNotFound, HttpResponseServerError -from PIL import Image from rest_framework import status -from rest_framework.test import APIClient, APITestCase -from cvat.apps.engine.tests.utils import filter_dict, get_paginated_collection +from cvat.apps.engine.tests.utils import ( + ApiTestBase, filter_dict, ForceLogin, generate_image_file, get_paginated_collection +) LAMBDA_ROOT_PATH = '/api/lambda' LAMBDA_FUNCTIONS_PATH = f'{LAMBDA_ROOT_PATH}/functions' @@ -49,35 +48,8 @@ with open(path) as f: functions = json.load(f) - -def generate_image_file(filename, size=(100, 100)): - f = BytesIO() - image = Image.new('RGB', size=size) - image.save(f, 'jpeg') - f.name = filename - f.seek(0) - return f - - -class ForceLogin: - def __init__(self, user, client): - self.user = user - self.client = client - - def __enter__(self): - if self.user: - self.client.force_login(self.user, backend='django.contrib.auth.backends.ModelBackend') - - return self - - def __exit__(self, exception_type, exception_value, traceback): - if self.user: - self.client.logout() - -class _LambdaTestCaseBase(APITestCase): +class _LambdaTestCaseBase(ApiTestBase): def setUp(self): - self.client = APIClient() - http_patcher = mock.patch('cvat.apps.lambda_manager.views.LambdaGateway._http', side_effect = self._get_data_from_lambda_manager_http) self.addCleanup(http_patcher.stop) http_patcher.start() From 88c34a3445bfc02bdc1c27fc00dcabe52af032bb Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 15 Aug 2024 18:46:16 +0300 Subject: [PATCH 062/227] Refactor some code --- cvat/apps/engine/media_extractors.py | 50 +++++++++++++++++++--------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 17c815695047..1567ff0574c1 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -193,11 +193,25 @@ def __getitem__(self, idx: int): class IMediaReader(ABC): - def __init__(self, source_path, step, start, stop, dimension): + def __init__( + self, + source_path, + *, + start: int = 0, + stop: Optional[int] = None, + step: int = 1, + dimension: DimensionType = DimensionType.DIM_2D + ): self._source_path = source_path + self._step = step + self._start = start + "The first included index" + self._stop = stop + "The last included index" + self._dimension = dimension @abstractmethod @@ -245,28 +259,27 @@ def _get_preview(obj): def get_image_size(self, i): pass + @abstractmethod def __len__(self): - return len(self.frame_range) - - @property - def frame_range(self): - return range(self._start, self._stop + 1, self._step) + pass class ImageListReader(IMediaReader): def __init__(self, - source_path, - step=1, - start=0, - stop=None, - dimension=DimensionType.DIM_2D, - sorting_method=SortingMethod.LEXICOGRAPHICAL): + source_path, + step: int = 1, + start: int = 0, + stop: Optional[int] = None, + dimension: DimensionType = DimensionType.DIM_2D, + sorting_method: SortingMethod = SortingMethod.LEXICOGRAPHICAL, + ): if not source_path: raise Exception('No image found') if not stop: - stop = max(0, len(source_path) - 1) + stop = len(source_path) - 1 else: - stop = max(0, min(len(source_path), stop + 1) - 1) + stop = min(len(source_path) - 1, stop) + step = max(step, 1) assert stop > start @@ -281,7 +294,7 @@ def __init__(self, self._sorting_method = sorting_method def __iter__(self): - for i in range(self._start, self._stop + 1, self._step): + for i in self.frame_range: yield (self.get_image(i), self.get_path(i), i) def __contains__(self, media_file): @@ -338,6 +351,13 @@ def reconcile(self, source_files, step=1, start=0, stop=None, dimension=Dimensio def absolute_source_paths(self): return [self.get_path(idx) for idx, _ in enumerate(self._source_path)] + def __len__(self): + return len(self.frame_range) + + @property + def frame_range(self): + return range(self._start, self._stop + 1, self._step) + class DirectoryReader(ImageListReader): def __init__(self, source_path, From b0fd0064fcee6dd8f59bfaefd57c5156d358c623 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 15 Aug 2024 21:11:47 +0300 Subject: [PATCH 063/227] Remove duplicated tests --- cvat/apps/engine/tests/test_rest_api.py | 42 ------------------------- 1 file changed, 42 deletions(-) diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index 47758be11d15..2f8a00b4111c 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -4115,48 +4115,6 @@ def _test_api_v2_tasks_id_data_create_can_use_server_images_and_manifest(self, u for i, fn in enumerate(images + [manifest_name]) }) - for copy_data in [True, False]: - with self.subTest(current_function_name(), copy=copy_data): - task_spec = task_spec_common.copy() - task_spec['name'] = task_spec['name'] + f' copy={copy_data}' - task_data['copy_data'] = copy_data - self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data, - self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, - image_sizes, StorageMethodChoice.CACHE, - StorageChoice.LOCAL if copy_data else StorageChoice.SHARE) - - with self.subTest(current_function_name() + ' file order mismatch'): - task_spec = task_spec_common.copy() - task_spec['name'] = task_spec['name'] + f' mismatching file order' - task_data_copy = task_data.copy() - task_data_copy[f'server_files[{len(images)}]'] = "images_manifest.jsonl" - self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data_copy, - self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, - image_sizes, StorageMethodChoice.CACHE, StorageChoice.SHARE, - expected_task_creation_status_state='Failed', - expected_task_creation_status_reason='Incorrect file mapping to manifest content') - - for copy_data in [True, False]: - with self.subTest(current_function_name(), copy=copy_data): - task_spec = task_spec_common.copy() - task_spec['name'] = task_spec['name'] + f' copy={copy_data}' - task_data['copy_data'] = copy_data - self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data, - self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, - image_sizes, StorageMethodChoice.CACHE, - StorageChoice.LOCAL if copy_data else StorageChoice.SHARE) - - with self.subTest(current_function_name() + ' file order mismatch'): - task_spec = task_spec_common.copy() - task_spec['name'] = task_spec['name'] + f' mismatching file order' - task_data_copy = task_data.copy() - task_data_copy[f'server_files[{len(images)}]'] = "images_manifest.jsonl" - self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data_copy, - self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, - image_sizes, StorageMethodChoice.CACHE, StorageChoice.SHARE, - expected_task_creation_status_state='Failed', - expected_task_creation_status_reason='Incorrect file mapping to manifest content') - for copy_data in [True, False]: with self.subTest(current_function_name(), copy=copy_data): task_spec = task_spec_common.copy() From 2eac04a2d0469eeae0c2b7b167a0108b32a5c317 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 15 Aug 2024 21:12:15 +0300 Subject: [PATCH 064/227] Remove extra change --- cvat/apps/engine/task.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 31eb2db3c652..8024ed4c3761 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1117,7 +1117,6 @@ def _update_status(msg: str) -> None: models.RelatedFile(data=image.data, primary_image=image, path=os.path.join(upload_dir, related_file_path)) for image in images for related_file_path in related_images.get(image.path, []) - if not image.is_placeholder # TODO ] models.RelatedFile.objects.bulk_create(db_related_files) else: From 640518ced993904647593889606d9203eb8634f6 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 15 Aug 2024 21:12:49 +0300 Subject: [PATCH 065/227] Fix method name, remove extra method --- cvat/apps/engine/cache.py | 2 +- cvat/apps/engine/media_extractors.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index d7af8b861535..ff1851b2f930 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -146,7 +146,7 @@ def get_or_set_cloud_preview(self, db_storage: models.CloudStorage) -> DataWithM def get_frame_context_images(self, db_data: models.Data, frame_number: int) -> DataWithMime: return self._get_or_set_cache_item( key=f"context_image_{db_data.id}_{frame_number}", - create_callback=lambda: self._prepare_context_image(db_data, frame_number), + create_callback=lambda: self.prepare_context_images(db_data, frame_number), ) def _read_raw_frames( diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 1567ff0574c1..afdeaba9efa6 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -259,10 +259,6 @@ def _get_preview(obj): def get_image_size(self, i): pass - @abstractmethod - def __len__(self): - pass - class ImageListReader(IMediaReader): def __init__(self, source_path, @@ -281,7 +277,7 @@ def __init__(self, stop = min(len(source_path) - 1, stop) step = max(step, 1) - assert stop > start + assert stop >= start super().__init__( source_path=sort(source_path, sorting_method), From 3a246b3f896fb809665d158963ce4f97a45a4cad Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 15 Aug 2024 21:15:46 +0300 Subject: [PATCH 066/227] Remove some shared code in tests, add temp data cleanup --- .../dataset_manager/tests/test_formats.py | 37 +++---------------- .../tests/test_rest_api_formats.py | 12 +++--- cvat/apps/engine/tests/test_rest_api_3D.py | 4 +- cvat/apps/lambda_manager/tests/test_lambda.py | 2 + 4 files changed, 16 insertions(+), 39 deletions(-) diff --git a/cvat/apps/dataset_manager/tests/test_formats.py b/cvat/apps/dataset_manager/tests/test_formats.py index 1c7db60814d0..ea5021d7d572 100644 --- a/cvat/apps/dataset_manager/tests/test_formats.py +++ b/cvat/apps/dataset_manager/tests/test_formats.py @@ -1,6 +1,6 @@ # Copyright (C) 2020-2022 Intel Corporation -# Copyright (C) 2022 CVAT.ai Corporation +# Copyright (C) 2022-2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -14,10 +14,8 @@ from datumaro.components.dataset import Dataset, DatasetItem from datumaro.components.annotation import Mask from django.contrib.auth.models import Group, User -from PIL import Image from rest_framework import status -from rest_framework.test import APIClient, APITestCase import cvat.apps.dataset_manager as dm from cvat.apps.dataset_manager.annotation import AnnotationIR @@ -26,36 +24,13 @@ from cvat.apps.dataset_manager.task import TaskAnnotation from cvat.apps.dataset_manager.util import make_zip_archive from cvat.apps.engine.models import Task -from cvat.apps.engine.tests.utils import get_paginated_collection +from cvat.apps.engine.tests.utils import ( + get_paginated_collection, ForceLogin, generate_image_file, ApiTestBase +) - -def generate_image_file(filename, size=(100, 100)): - f = BytesIO() - image = Image.new('RGB', size=size) - image.save(f, 'jpeg') - f.name = filename - f.seek(0) - return f - -class ForceLogin: - def __init__(self, user, client): - self.user = user - self.client = client - - def __enter__(self): - if self.user: - self.client.force_login(self.user, - backend='django.contrib.auth.backends.ModelBackend') - - return self - - def __exit__(self, exception_type, exception_value, traceback): - if self.user: - self.client.logout() - -class _DbTestBase(APITestCase): +class _DbTestBase(ApiTestBase): def setUp(self): - self.client = APIClient() + super().setUp() @classmethod def setUpTestData(cls): diff --git a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py index 767fb07fe962..6ed6bbee3d53 100644 --- a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py +++ b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py @@ -419,7 +419,7 @@ def test_api_v2_dump_and_upload_annotations_with_objects_type_is_shape(self): url = self._generate_url_dump_tasks_annotations(task_id) for user, edata in list(expected.items()): - self._clear_rq_jobs() # clean up from previous tests and iterations + self._clear_temp_data() # clean up from previous tests and iterations user_name = edata['name'] file_zip_name = osp.join(test_dir, f'{test_name}_{user_name}_{dump_format_name}.zip') @@ -526,7 +526,7 @@ def test_api_v2_dump_annotations_with_objects_type_is_track(self): url = self._generate_url_dump_tasks_annotations(task_id) for user, edata in list(expected.items()): - self._clear_rq_jobs() # clean up from previous tests and iterations + self._clear_temp_data() # clean up from previous tests and iterations user_name = edata['name'] file_zip_name = osp.join(test_dir, f'{test_name}_{user_name}_{dump_format_name}.zip') @@ -613,7 +613,7 @@ def test_api_v2_dump_tag_annotations(self): for user, edata in list(expected.items()): with self.subTest(format=f"{edata['name']}"): with TestDir() as test_dir: - self._clear_rq_jobs() # clean up from previous tests and iterations + self._clear_temp_data() # clean up from previous tests and iterations user_name = edata['name'] url = self._generate_url_dump_tasks_annotations(task_id) @@ -857,7 +857,7 @@ def test_api_v2_export_dataset(self): # dump annotations url = self._generate_url_dump_task_dataset(task_id) for user, edata in list(expected.items()): - self._clear_rq_jobs() # clean up from previous tests and iterations + self._clear_temp_data() # clean up from previous tests and iterations user_name = edata['name'] file_zip_name = osp.join(test_dir, f'{test_name}_{user_name}_{dump_format_name}.zip') @@ -2058,7 +2058,7 @@ def test_api_v2_export_import_dataset(self): self._create_annotations(task, dump_format_name, "random") for user, edata in list(expected.items()): - self._clear_rq_jobs() # clean up from previous tests and iterations + self._clear_temp_data() # clean up from previous tests and iterations user_name = edata['name'] file_zip_name = osp.join(test_dir, f'{test_name}_{user_name}_{dump_format_name}.zip') @@ -2140,7 +2140,7 @@ def test_api_v2_export_annotations(self): url = self._generate_url_dump_project_annotations(project['id'], dump_format_name) for user, edata in list(expected.items()): - self._clear_rq_jobs() # clean up from previous tests and iterations + self._clear_temp_data() # clean up from previous tests and iterations user_name = edata['name'] file_zip_name = osp.join(test_dir, f'{test_name}_{user_name}_{dump_format_name}.zip') diff --git a/cvat/apps/engine/tests/test_rest_api_3D.py b/cvat/apps/engine/tests/test_rest_api_3D.py index a67a79109f33..0bdd3a001055 100644 --- a/cvat/apps/engine/tests/test_rest_api_3D.py +++ b/cvat/apps/engine/tests/test_rest_api_3D.py @@ -527,7 +527,7 @@ def test_api_v2_dump_and_upload_annotation(self): for user, edata in list(self.expected_dump_upload.items()): with self.subTest(format=f"{format_name}_{edata['name']}_dump"): - self._clear_rq_jobs() # clean up from previous tests and iterations + self._clear_temp_data() # clean up from previous tests and iterations url = self._generate_url_dump_tasks_annotations(task_id) file_name = osp.join(test_dir, f"{format_name}_{edata['name']}.zip") @@ -718,7 +718,7 @@ def test_api_v2_export_dataset(self): for user, edata in list(self.expected_dump_upload.items()): with self.subTest(format=f"{format_name}_{edata['name']}_export"): - self._clear_rq_jobs() # clean up from previous tests and iterations + self._clear_temp_data() # clean up from previous tests and iterations url = self._generate_url_dump_dataset(task_id) file_name = osp.join(test_dir, f"{format_name}_{edata['name']}.zip") diff --git a/cvat/apps/lambda_manager/tests/test_lambda.py b/cvat/apps/lambda_manager/tests/test_lambda.py index 51973fdbd4a0..7d57332c3302 100644 --- a/cvat/apps/lambda_manager/tests/test_lambda.py +++ b/cvat/apps/lambda_manager/tests/test_lambda.py @@ -50,6 +50,8 @@ class _LambdaTestCaseBase(ApiTestBase): def setUp(self): + super().setUp() + http_patcher = mock.patch('cvat.apps.lambda_manager.views.LambdaGateway._http', side_effect = self._get_data_from_lambda_manager_http) self.addCleanup(http_patcher.stop) http_patcher.start() From a0704f46d190b00f9604e8a51e77c0363f0213d5 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 15 Aug 2024 21:18:52 +0300 Subject: [PATCH 067/227] Add checks for successful task creation in tests --- .../dataset_manager/tests/test_formats.py | 5 +++++ .../tests/test_rest_api_formats.py | 5 +++++ cvat/apps/engine/tests/test_rest_api.py | 20 ++++++++++++++++++- cvat/apps/engine/tests/test_rest_api_3D.py | 8 ++++++-- cvat/apps/lambda_manager/tests/test_lambda.py | 5 +++++ 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/cvat/apps/dataset_manager/tests/test_formats.py b/cvat/apps/dataset_manager/tests/test_formats.py index ea5021d7d572..f83672f14752 100644 --- a/cvat/apps/dataset_manager/tests/test_formats.py +++ b/cvat/apps/dataset_manager/tests/test_formats.py @@ -69,6 +69,11 @@ def _create_task(self, data, image_data): response = self.client.post("/api/tasks/%s/data" % tid, data=image_data) assert response.status_code == status.HTTP_202_ACCEPTED, response.status_code + rq_id = response.json()["rq_id"] + + response = self.client.get(f"/api/requests/{rq_id}") + assert response.status_code == status.HTTP_200_OK, response.status_code + assert response.json()["status"] == "finished", response.json().get("status") response = self.client.get("/api/tasks/%s" % tid) diff --git a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py index 6ed6bbee3d53..bc23a253ee18 100644 --- a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py +++ b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py @@ -151,6 +151,11 @@ def _create_task(self, data, image_data): response = self.client.post("/api/tasks/%s/data" % tid, data=image_data) assert response.status_code == status.HTTP_202_ACCEPTED, response.status_code + rq_id = response.json()["rq_id"] + + response = self.client.get(f"/api/requests/{rq_id}") + assert response.status_code == status.HTTP_200_OK, response.status_code + assert response.json()["status"] == "finished", response.json().get("status") response = self.client.get("/api/tasks/%s" % tid) diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index 2f8a00b4111c..16356ef8a0a9 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -1422,7 +1422,13 @@ def _create_task(task_data, media_data): if isinstance(media, io.BytesIO): media.seek(0) response = cls.client.post("/api/tasks/{}/data".format(tid), data=media_data) - assert response.status_code == status.HTTP_202_ACCEPTED + assert response.status_code == status.HTTP_202_ACCEPTED, response.status_code + rq_id = response.json()["rq_id"] + + response = cls.client.get(f"/api/requests/{rq_id}") + assert response.status_code == status.HTTP_200_OK, response.status_code + assert response.json()["status"] == "finished", response.json().get("status") + response = cls.client.get("/api/tasks/{}".format(tid)) data_id = response.data["data"] cls.tasks.append({ @@ -1766,6 +1772,12 @@ def _create_task(task_data, media_data): media.seek(0) response = self.client.post("/api/tasks/{}/data".format(tid), data=media_data) assert response.status_code == status.HTTP_202_ACCEPTED + rq_id = response.json()["rq_id"] + + response = self.client.get(f"/api/requests/{rq_id}") + assert response.status_code == status.HTTP_200_OK, response.status_code + assert response.json()["status"] == "finished", response.json().get("status") + response = self.client.get("/api/tasks/{}".format(tid)) data_id = response.data["data"] self.tasks.append({ @@ -2882,6 +2894,12 @@ def _create_task(task_data, media_data): media.seek(0) response = self.client.post("/api/tasks/{}/data".format(tid), data=media_data) assert response.status_code == status.HTTP_202_ACCEPTED + rq_id = response.json()["rq_id"] + + response = self.client.get(f"/api/requests/{rq_id}") + assert response.status_code == status.HTTP_200_OK, response.status_code + assert response.json()["status"] == "finished", response.json().get("status") + response = self.client.get("/api/tasks/{}".format(tid)) data_id = response.data["data"] self.tasks.append({ diff --git a/cvat/apps/engine/tests/test_rest_api_3D.py b/cvat/apps/engine/tests/test_rest_api_3D.py index 0bdd3a001055..785eb8755794 100644 --- a/cvat/apps/engine/tests/test_rest_api_3D.py +++ b/cvat/apps/engine/tests/test_rest_api_3D.py @@ -86,9 +86,13 @@ def _create_task(self, data, image_data): assert response.status_code == status.HTTP_201_CREATED, response.status_code tid = response.data["id"] - response = self.client.post("/api/tasks/%s/data" % tid, - data=image_data) + response = self.client.post("/api/tasks/%s/data" % tid, data=image_data) assert response.status_code == status.HTTP_202_ACCEPTED, response.status_code + rq_id = response.json()["rq_id"] + + response = self.client.get(f"/api/requests/{rq_id}") + assert response.status_code == status.HTTP_200_OK, response.status_code + assert response.json()["status"] == "finished", response.json().get("status") response = self.client.get("/api/tasks/%s" % tid) diff --git a/cvat/apps/lambda_manager/tests/test_lambda.py b/cvat/apps/lambda_manager/tests/test_lambda.py index 7d57332c3302..92b51862e2ab 100644 --- a/cvat/apps/lambda_manager/tests/test_lambda.py +++ b/cvat/apps/lambda_manager/tests/test_lambda.py @@ -155,6 +155,11 @@ def _create_task(self, task_spec, data, *, owner=None, org_id=None): data=data, QUERY_STRING=f'org_id={org_id}' if org_id is not None else None) assert response.status_code == status.HTTP_202_ACCEPTED, response.status_code + rq_id = response.json()["rq_id"] + + response = self.client.get(f"/api/requests/{rq_id}") + assert response.status_code == status.HTTP_200_OK, response.status_code + assert response.json()["status"] == "finished", response.json().get("status") response = self.client.get("/api/tasks/%s" % tid, QUERY_STRING=f'org_id={org_id}' if org_id is not None else None) From cf026ef343565cd18e8753fe6990257496a4e656 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 15 Aug 2024 21:19:13 +0300 Subject: [PATCH 068/227] Fix invalid variable access in test --- cvat/apps/engine/tests/test_rest_api_3D.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cvat/apps/engine/tests/test_rest_api_3D.py b/cvat/apps/engine/tests/test_rest_api_3D.py index 785eb8755794..9f000be5d218 100644 --- a/cvat/apps/engine/tests/test_rest_api_3D.py +++ b/cvat/apps/engine/tests/test_rest_api_3D.py @@ -744,6 +744,8 @@ def test_api_v2_export_dataset(self): content = io.BytesIO(b"".join(response.streaming_content)) with open(file_name, "wb") as f: f.write(content.getvalue()) - self.assertEqual(osp.exists(file_name), edata['file_exists']) - self._check_dump_content(content, task_ann_prev.data, format_name,related_files=False) + self.assertEqual(osp.exists(file_name), edata['file_exists']) + self._check_dump_content( + content, task_ann_prev.data, format_name, related_files=False + ) From f73cef30db9f070d2d4743426878922c16754e44 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 15 Aug 2024 21:40:01 +0300 Subject: [PATCH 069/227] Update default cache location in test checks --- cvat/apps/engine/tests/test_rest_api.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index 16356ef8a0a9..8c08948166ac 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -3451,7 +3451,7 @@ def _test_api_v2_tasks_id_data_spec(self, user, spec, data, expected_compressed_type, expected_original_type, expected_image_sizes, - expected_storage_method=StorageMethodChoice.FILE_SYSTEM, + expected_storage_method=None, expected_uploaded_data_location=StorageChoice.LOCAL, dimension=DimensionType.DIM_2D, expected_task_creation_status_state='Finished', @@ -3466,6 +3466,12 @@ def _test_api_v2_tasks_id_data_spec(self, user, spec, data, if get_status_callback is None: get_status_callback = self._get_task_creation_status + if expected_storage_method is None: + if settings.MEDIA_CACHE_ALLOW_STATIC_CACHE: + expected_storage_method = StorageMethodChoice.FILE_SYSTEM + else: + expected_storage_method = StorageMethodChoice.CACHE + # create task response = self._create_task(user, spec) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -4025,7 +4031,7 @@ def _test_api_v2_tasks_id_data_create_can_use_chunked_local_video(self, user): image_sizes = self._share_image_sizes['test_rotated_90_video.mp4'] self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data, self.ChunkType.IMAGESET, - self.ChunkType.VIDEO, image_sizes, StorageMethodChoice.FILE_SYSTEM) + self.ChunkType.VIDEO, image_sizes, StorageMethodChoice.CACHE) def _test_api_v2_tasks_id_data_create_can_use_chunked_cached_local_video(self, user): task_spec = { @@ -4195,7 +4201,7 @@ def _test_api_v2_tasks_id_data_create_can_use_server_images_with_predefined_sort task_data = task_data_common.copy() task_data["use_cache"] = caching_enabled - if caching_enabled: + if caching_enabled or not settings.MEDIA_CACHE_ALLOW_STATIC_CACHE: storage_method = StorageMethodChoice.CACHE else: storage_method = StorageMethodChoice.FILE_SYSTEM @@ -4254,7 +4260,7 @@ def _test_api_v2_tasks_id_data_create_can_use_local_images_with_predefined_sorti sorting_method=SortingMethod.PREDEFINED) task_data_common["use_cache"] = caching_enabled - if caching_enabled: + if caching_enabled or not settings.MEDIA_CACHE_ALLOW_STATIC_CACHE: storage_method = StorageMethodChoice.CACHE else: storage_method = StorageMethodChoice.FILE_SYSTEM @@ -4315,7 +4321,7 @@ def _test_api_v2_tasks_id_data_create_can_use_server_archive_with_predefined_sor task_data = task_data_common.copy() task_data["use_cache"] = caching_enabled - if caching_enabled: + if caching_enabled or not settings.MEDIA_CACHE_ALLOW_STATIC_CACHE: storage_method = StorageMethodChoice.CACHE else: storage_method = StorageMethodChoice.FILE_SYSTEM @@ -4388,7 +4394,7 @@ def _test_api_v2_tasks_id_data_create_can_use_local_archive_with_predefined_sort sorting_method=SortingMethod.PREDEFINED) task_data["use_cache"] = caching_enabled - if caching_enabled: + if caching_enabled or not settings.MEDIA_CACHE_ALLOW_STATIC_CACHE: storage_method = StorageMethodChoice.CACHE else: storage_method = StorageMethodChoice.FILE_SYSTEM @@ -4566,7 +4572,7 @@ def _send_data_and_fail(*args, **kwargs): self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data, self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, - image_sizes, StorageMethodChoice.FILE_SYSTEM, StorageChoice.LOCAL, + image_sizes, expected_uploaded_data_location=StorageChoice.LOCAL, send_data_callback=_send_data) with self.subTest(current_function_name() + ' mismatching file sets - extra files'): @@ -4580,7 +4586,7 @@ def _send_data_and_fail(*args, **kwargs): with self.assertRaisesMessage(Exception, "(extra)"): self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data, self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, - image_sizes, StorageMethodChoice.FILE_SYSTEM, StorageChoice.LOCAL, + image_sizes, expected_uploaded_data_location=StorageChoice.LOCAL, send_data_callback=_send_data_and_fail) with self.subTest(current_function_name() + ' mismatching file sets - missing files'): @@ -4594,7 +4600,7 @@ def _send_data_and_fail(*args, **kwargs): with self.assertRaisesMessage(Exception, "(missing)"): self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data, self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, - image_sizes, StorageMethodChoice.FILE_SYSTEM, StorageChoice.LOCAL, + image_sizes, expected_uploaded_data_location=StorageChoice.LOCAL, send_data_callback=_send_data_and_fail) def _test_api_v2_tasks_id_data_create_can_use_server_rar(self, user): From 258c800d9101e03bd99a9160508a73317bbfefdc Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 16 Aug 2024 11:46:25 +0300 Subject: [PATCH 070/227] Update manifest validation logic, allow manifest input in any task data source --- cvat/apps/engine/task.py | 57 ++++++++----------------- cvat/apps/engine/tests/test_rest_api.py | 56 +++++++++++------------- 2 files changed, 43 insertions(+), 70 deletions(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 8024ed4c3761..eb2da9245093 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -340,48 +340,28 @@ def _validate_manifest( *, is_in_cloud: bool, db_cloud_storage: Optional[Any], - data_storage_method: str, - data_sorting_method: str, - isBackupRestore: bool, ) -> Optional[str]: - if manifests: - if len(manifests) != 1: - raise ValidationError('Only one manifest file can be attached to data') - manifest_file = manifests[0] - full_manifest_path = os.path.join(root_dir, manifests[0]) - - if is_in_cloud: - cloud_storage_instance = db_storage_to_storage_instance(db_cloud_storage) - # check that cloud storage manifest file exists and is up to date - if not os.path.exists(full_manifest_path) or \ - datetime.fromtimestamp(os.path.getmtime(full_manifest_path), tz=timezone.utc) \ - < cloud_storage_instance.get_file_last_modified(manifest_file): - cloud_storage_instance.download_file(manifest_file, full_manifest_path) - - if is_manifest(full_manifest_path): - if not ( - data_sorting_method == models.SortingMethod.PREDEFINED or - (settings.USE_CACHE and data_storage_method == models.StorageMethodChoice.CACHE) or - isBackupRestore or is_in_cloud - ): - cache_disabled_message = "" - if data_storage_method == models.StorageMethodChoice.CACHE and not settings.USE_CACHE: - cache_disabled_message = ( - "This server doesn't allow to use cache for data. " - "Please turn 'use cache' off and try to recreate the task" - ) - slogger.glob.warning(cache_disabled_message) + if not manifests: + return None - raise ValidationError( - "A manifest file can only be used with the 'use cache' option " - "or when 'sorting_method' is 'predefined'" + \ - (". " + cache_disabled_message if cache_disabled_message else "") - ) - return manifest_file + if len(manifests) != 1: + raise ValidationError('Only one manifest file can be attached to data') + manifest_file = manifests[0] + full_manifest_path = os.path.join(root_dir, manifests[0]) + + if is_in_cloud: + cloud_storage_instance = db_storage_to_storage_instance(db_cloud_storage) + # check that cloud storage manifest file exists and is up to date + if not os.path.exists(full_manifest_path) or ( + datetime.fromtimestamp(os.path.getmtime(full_manifest_path), tz=timezone.utc) \ + < cloud_storage_instance.get_file_last_modified(manifest_file) + ): + cloud_storage_instance.download_file(manifest_file, full_manifest_path) + if not is_manifest(full_manifest_path): raise ValidationError('Invalid manifest was uploaded') - return None + return manifest_file def _validate_scheme(url): ALLOWED_SCHEMES = ['http', 'https'] @@ -580,9 +560,6 @@ def _update_status(msg: str) -> None: manifest_root, is_in_cloud=is_data_in_cloud, db_cloud_storage=db_data.cloud_storage if is_data_in_cloud else None, - data_storage_method=db_data.storage_method, - data_sorting_method=data['sorting_method'], - isBackupRestore=isBackupRestore, ) manifest = None diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index 8c08948166ac..36fd9ed00d24 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -4128,7 +4128,6 @@ def _test_api_v2_tasks_id_data_create_can_use_server_images_and_manifest(self, u task_data = { "image_quality": 70, - "use_cache": True } manifest_name = "images_manifest_sorted.jsonl" @@ -4139,37 +4138,34 @@ def _test_api_v2_tasks_id_data_create_can_use_server_images_and_manifest(self, u for i, fn in enumerate(images + [manifest_name]) }) - for copy_data in [True, False]: - with self.subTest(current_function_name(), copy=copy_data): + for use_cache in [True, False]: + task_data['use_cache'] = use_cache + + for copy_data in [True, False]: + with self.subTest(current_function_name(), copy=copy_data, use_cache=use_cache): + task_spec = task_spec_common.copy() + task_spec['name'] = task_spec['name'] + f' copy={copy_data}' + task_data_copy = task_data.copy() + task_data_copy['copy_data'] = copy_data + self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data_copy, + self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, + image_sizes, + expected_uploaded_data_location=( + StorageChoice.LOCAL if copy_data else StorageChoice.SHARE + ) + ) + + with self.subTest(current_function_name() + ' file order mismatch', use_cache=use_cache): task_spec = task_spec_common.copy() - task_spec['name'] = task_spec['name'] + f' copy={copy_data}' - task_data['copy_data'] = copy_data - self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data, + task_spec['name'] = task_spec['name'] + f' mismatching file order' + task_data_copy = task_data.copy() + task_data_copy[f'server_files[{len(images)}]'] = "images_manifest.jsonl" + self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data_copy, self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, - image_sizes, StorageMethodChoice.CACHE, - StorageChoice.LOCAL if copy_data else StorageChoice.SHARE) - - with self.subTest(current_function_name() + ' file order mismatch'): - task_spec = task_spec_common.copy() - task_spec['name'] = task_spec['name'] + f' mismatching file order' - task_data_copy = task_data.copy() - task_data_copy[f'server_files[{len(images)}]'] = "images_manifest.jsonl" - self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data_copy, - self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, - image_sizes, StorageMethodChoice.CACHE, StorageChoice.SHARE, - expected_task_creation_status_state='Failed', - expected_task_creation_status_reason='Incorrect file mapping to manifest content') - - with self.subTest(current_function_name() + ' without use cache'): - task_spec = task_spec_common.copy() - task_spec['name'] = task_spec['name'] + f' manifest without cache' - task_data_copy = task_data.copy() - task_data_copy['use_cache'] = False - self._test_api_v2_tasks_id_data_spec(user, task_spec, task_data_copy, - self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, - image_sizes, StorageMethodChoice.CACHE, StorageChoice.SHARE, - expected_task_creation_status_state='Failed', - expected_task_creation_status_reason="A manifest file can only be used with the 'use cache' option") + image_sizes, + expected_uploaded_data_location=StorageChoice.SHARE, + expected_task_creation_status_state='Failed', + expected_task_creation_status_reason='Incorrect file mapping to manifest content') def _test_api_v2_tasks_id_data_create_can_use_server_images_with_predefined_sorting(self, user): task_spec = { From 5e89ef49074715706b2f96ba2b936fc103d1d547 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 16 Aug 2024 14:05:55 +0300 Subject: [PATCH 071/227] Add task chunk caching, refactor chunk building --- cvat/apps/engine/cache.py | 119 ++++++++++++++++++++--------- cvat/apps/engine/frame_provider.py | 93 ++++++++-------------- 2 files changed, 115 insertions(+), 97 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index ff1851b2f930..a16071d446ea 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -15,7 +15,7 @@ from contextlib import ExitStack, closing from datetime import datetime, timezone from itertools import pairwise -from typing import Callable, Iterator, Optional, Sequence, Tuple, Type, Union +from typing import Any, Callable, Iterator, Optional, Sequence, Tuple, Type, Union import av import cv2 @@ -118,6 +118,29 @@ def get_segment_chunk( ), ) + def _make_task_chunk_key( + self, db_task: models.Task, chunk_number: int, *, quality: FrameQuality + ) -> str: + return f"task_{db_task.id}_{chunk_number}_{quality}" + + def get_task_chunk( + self, db_task: models.Task, chunk_number: int, *, quality: FrameQuality + ) -> Optional[DataWithMime]: + return self._get(key=self._make_task_chunk_key(db_task, chunk_number, quality=quality)) + + def get_or_set_task_chunk( + self, + db_task: models.Task, + chunk_number: int, + *, + quality: FrameQuality, + set_callback: Callable[[], DataWithMime], + ) -> DataWithMime: + return self._get_or_set_cache_item( + key=self._make_task_chunk_key(db_task, chunk_number, quality=quality), + create_callback=lambda: set_callback(db_task, chunk_number, quality=quality), + ) + def get_selective_job_chunk( self, db_job: models.Job, chunk_number: int, *, quality: FrameQuality ) -> DataWithMime: @@ -298,43 +321,12 @@ def prepare_range_segment_chunk( chunk_size * chunk_number : chunk_size * (chunk_number + 1) ] - writer_classes: dict[FrameQuality, Type[IChunkWriter]] = { - FrameQuality.COMPRESSED: ( - Mpeg4CompressedChunkWriter - if db_data.compressed_chunk_type == models.DataChoice.VIDEO - else ZipCompressedChunkWriter - ), - FrameQuality.ORIGINAL: ( - Mpeg4ChunkWriter - if db_data.original_chunk_type == models.DataChoice.VIDEO - else ZipChunkWriter - ), - } - - image_quality = 100 if quality == FrameQuality.ORIGINAL else db_data.image_quality - - mime_type = ( - "video/mp4" - if writer_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] - else "application/zip" - ) - - kwargs = {} - if db_segment.task.dimension == models.DimensionType.DIM_3D: - kwargs["dimension"] = models.DimensionType.DIM_3D - writer = writer_classes[quality](image_quality, **kwargs) - - buffer = io.BytesIO() with closing(self._read_raw_frames(db_task, frame_ids=chunk_frame_ids)) as frame_iter: - writer.save_as_chunk(frame_iter, buffer) - - buffer.seek(0) - return buffer, mime_type + return prepare_chunk(frame_iter, quality=quality, db_task=db_task) def prepare_masked_range_segment_chunk( self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality ) -> DataWithMime: - # TODO: try to refactor into 1 function with prepare_range_segment_chunk() db_task = db_segment.task db_data = db_task.data @@ -395,7 +387,7 @@ def prepare_masked_range_segment_chunk( ) buff.seek(0) - return buff, "application/zip" + return buff, get_chunk_mime_type_for_writer(writer) def _prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: if db_segment.task.dimension == models.DimensionType.DIM_3D: @@ -405,7 +397,9 @@ def _prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: ) else: from cvat.apps.engine.frame_provider import ( - FrameOutputType, SegmentFrameProvider, TaskFrameProvider + FrameOutputType, + SegmentFrameProvider, + TaskFrameProvider, ) task_frame_provider = TaskFrameProvider(db_segment.task) @@ -494,3 +488,58 @@ def prepare_preview_image(image: PIL.Image.Image) -> DataWithMime: output_buf = io.BytesIO() image.convert("RGB").save(output_buf, format="JPEG") return output_buf, PREVIEW_MIME + + +def prepare_chunk( + task_chunk_frames: Iterator[Tuple[Any, str, int]], + *, + quality: FrameQuality, + db_task: models.Task, +) -> DataWithMime: + # TODO: refactor all chunk building into another class + + db_data = db_task.data + + writer_classes: dict[FrameQuality, Type[IChunkWriter]] = { + FrameQuality.COMPRESSED: ( + Mpeg4CompressedChunkWriter + if db_data.compressed_chunk_type == models.DataChoice.VIDEO + else ZipCompressedChunkWriter + ), + FrameQuality.ORIGINAL: ( + Mpeg4ChunkWriter + if db_data.original_chunk_type == models.DataChoice.VIDEO + else ZipChunkWriter + ), + } + + writer_class = writer_classes[quality] + + image_quality = 100 if quality == FrameQuality.ORIGINAL else db_data.image_quality + + writer_kwargs = {} + if db_task.dimension == models.DimensionType.DIM_3D: + writer_kwargs["dimension"] = models.DimensionType.DIM_3D + merged_chunk_writer = writer_class(image_quality, **writer_kwargs) + + writer_kwargs = {} + if isinstance(merged_chunk_writer, ZipCompressedChunkWriter): + writer_kwargs = dict(compress_frames=False, zip_compress_level=1) + + buffer = io.BytesIO() + merged_chunk_writer.save_as_chunk(task_chunk_frames, buffer, **writer_kwargs) + + buffer.seek(0) + return buffer, get_chunk_mime_type_for_writer(writer_class) + + +def get_chunk_mime_type_for_writer(writer: Union[IChunkWriter, Type[IChunkWriter]]) -> str: + if isinstance(writer, IChunkWriter): + writer_class = type(writer) + + if issubclass(writer_class, ZipChunkWriter): + return "application/zip" + elif issubclass(writer_class, Mpeg4ChunkWriter): + return "video/mp4" + else: + assert False, f"Unknown chunk writer class {writer_class}" diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 8ce5be00f5a2..28e6c396a764 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -22,17 +22,12 @@ from rest_framework.exceptions import ValidationError from cvat.apps.engine import models -from cvat.apps.engine.cache import DataWithMime, MediaCache +from cvat.apps.engine.cache import DataWithMime, MediaCache, prepare_chunk from cvat.apps.engine.media_extractors import ( FrameQuality, - IChunkWriter, IMediaReader, - Mpeg4ChunkWriter, - Mpeg4CompressedChunkWriter, RandomAccessIterator, VideoReader, - ZipChunkWriter, - ZipCompressedChunkWriter, ZipReader, ) from cvat.apps.engine.mime_types import mimetypes @@ -261,6 +256,12 @@ def get_chunk( chunk_number = self.validate_chunk_number(chunk_number) db_data = self._db_task.data + + cache = MediaCache() + cached_chunk = cache.get_task_chunk(self._db_task, chunk_number, quality=quality) + if cached_chunk: + return return_type(cached_chunk[0], cached_chunk[1]) + step = db_data.get_frame_step() task_chunk_start_frame = chunk_number * db_data.chunk_size task_chunk_stop_frame = (chunk_number + 1) * db_data.chunk_size - 1 @@ -283,6 +284,7 @@ def get_chunk( ) assert matching_segments + # Don't put this into set_callback to avoid data duplication in the cache if len(matching_segments) == 1 and task_chunk_frame_set == set( matching_segments[0].frame_set ): @@ -291,64 +293,31 @@ def get_chunk( segment_frame_provider.get_chunk_number(task_chunk_start_frame), quality=quality ) - # Create and return a joined / cleaned chunk - # TODO: refactor into another class, maybe optimize - - writer_classes: dict[FrameQuality, Type[IChunkWriter]] = { - FrameQuality.COMPRESSED: ( - Mpeg4CompressedChunkWriter - if db_data.compressed_chunk_type == models.DataChoice.VIDEO - else ZipCompressedChunkWriter - ), - FrameQuality.ORIGINAL: ( - Mpeg4ChunkWriter - if db_data.original_chunk_type == models.DataChoice.VIDEO - else ZipChunkWriter - ), - } - - writer_class = writer_classes[quality] - - image_quality = 100 if quality == FrameQuality.ORIGINAL else db_data.image_quality - - mime_type = ( - "video/mp4" - if writer_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] - else "application/zip" + 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) + + buffer, mime_type = cache.get_or_set_task_chunk( + self._db_task, chunk_number, quality=quality, set_callback=_set_callback ) - 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) - - writer_kwargs = {} - if self._db_task.dimension == models.DimensionType.DIM_3D: - writer_kwargs["dimension"] = models.DimensionType.DIM_3D - merged_chunk_writer = writer_class(image_quality, **writer_kwargs) - - writer_kwargs = {} - if isinstance(merged_chunk_writer, ZipCompressedChunkWriter): - writer_kwargs = dict(compress_frames=False, zip_compress_level=1) - - buffer = io.BytesIO() - merged_chunk_writer.save_as_chunk(task_chunk_frames.values(), buffer, **writer_kwargs) - buffer.seek(0) - - # TODO: add caching in media cache for the resulting chunk - return return_type(data=buffer, mime=mime_type) def get_frame( From c5edcda519c20f07c3f62233e1ff903dd0302952 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 16 Aug 2024 14:24:49 +0300 Subject: [PATCH 072/227] Refactor some code --- cvat/apps/engine/cache.py | 175 ++++++++++++++++++++------------------ 1 file changed, 93 insertions(+), 82 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index a16071d446ea..b2b0be9a1515 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -15,7 +15,7 @@ from contextlib import ExitStack, closing from datetime import datetime, timezone from itertools import pairwise -from typing import Any, Callable, Iterator, Optional, Sequence, Tuple, Type, Union +from typing import Any, Callable, Generator, Iterator, Optional, Sequence, Tuple, Type, Union import av import cv2 @@ -172,9 +172,97 @@ def get_frame_context_images(self, db_data: models.Data, frame_number: int) -> D create_callback=lambda: self.prepare_context_images(db_data, frame_number), ) + def _read_raw_images( + self, + db_task: models.Task, + frame_ids: Sequence[int], + *, + raw_data_dir: str, + manifest_path: str, + ): + db_data = db_task.data + dimension = db_task.dimension + + if os.path.isfile(manifest_path) and db_data.storage == models.StorageChoice.CLOUD_STORAGE: + reader = ImageReaderWithManifest(manifest_path) + with ExitStack() as es: + db_cloud_storage = db_data.cloud_storage + assert db_cloud_storage, "Cloud storage instance was deleted" + credentials = Credentials() + credentials.convert_from_db( + { + "type": db_cloud_storage.credentials_type, + "value": db_cloud_storage.credentials, + } + ) + details = { + "resource": db_cloud_storage.resource, + "credentials": credentials, + "specific_attributes": db_cloud_storage.get_specific_attributes(), + } + cloud_storage_instance = get_cloud_storage_instance( + cloud_provider=db_cloud_storage.provider_type, **details + ) + + tmp_dir = es.enter_context(tempfile.TemporaryDirectory(prefix="cvat")) + files_to_download = [] + checksums = [] + media = [] + for item in reader.iterate_frames(frame_ids): + file_name = f"{item['name']}{item['extension']}" + fs_filename = os.path.join(tmp_dir, file_name) + + files_to_download.append(file_name) + checksums.append(item.get("checksum", None)) + media.append((fs_filename, fs_filename, None)) + + cloud_storage_instance.bulk_download_to_dir( + files=files_to_download, upload_dir=tmp_dir + ) + media = preload_images(media) + + for checksum, (_, fs_filename, _) in zip(checksums, media): + if checksum and not md5_hash(fs_filename) == checksum: + slogger.cloud_storage[db_cloud_storage.id].warning( + "Hash sums of files {} do not match".format(file_name) + ) + + yield from media + else: + requested_frame_iter = iter(frame_ids) + next_requested_frame_id = next(requested_frame_iter, None) + if next_requested_frame_id is None: + return + + # TODO: find a way to use prefetched results, if provided + db_images = ( + db_data.images.order_by("frame") + .filter(frame__gte=frame_ids[0], frame__lte=frame_ids[-1]) + .values_list("frame", "path") + .all() + ) + + media = [] + for frame_id, frame_path in db_images: + if frame_id == next_requested_frame_id: + source_path = os.path.join(raw_data_dir, frame_path) + media.append((source_path, source_path, None)) + + next_requested_frame_id = next(requested_frame_iter, None) + + if next_requested_frame_id is None: + break + + assert next_requested_frame_id is None + + if dimension == models.DimensionType.DIM_2D: + media = preload_images(media) + + yield from media + def _read_raw_frames( self, db_task: models.Task, frame_ids: Sequence[int] - ) -> Iterator[Tuple[Union[av.VideoFrame, PIL.Image.Image], str, str]]: + ) -> Generator[Tuple[Union[av.VideoFrame, PIL.Image.Image], str, str], None, None]: for prev_frame, cur_frame in pairwise(frame_ids): assert ( prev_frame <= cur_frame @@ -188,7 +276,6 @@ def _read_raw_frames( models.StorageChoice.CLOUD_STORAGE: db_data.get_upload_dirname(), }[db_data.storage] - dimension = db_task.dimension manifest_path = db_data.get_manifest_path() if hasattr(db_data, "video"): @@ -218,85 +305,9 @@ def _read_raw_frames( for frame_tuple in reader.iterate_frames(frame_filter=frame_ids): yield frame_tuple else: - if ( - os.path.isfile(manifest_path) - and db_data.storage == models.StorageChoice.CLOUD_STORAGE - ): - reader = ImageReaderWithManifest(manifest_path) - with ExitStack() as es: - db_cloud_storage = db_data.cloud_storage - assert db_cloud_storage, "Cloud storage instance was deleted" - credentials = Credentials() - credentials.convert_from_db( - { - "type": db_cloud_storage.credentials_type, - "value": db_cloud_storage.credentials, - } - ) - details = { - "resource": db_cloud_storage.resource, - "credentials": credentials, - "specific_attributes": db_cloud_storage.get_specific_attributes(), - } - cloud_storage_instance = get_cloud_storage_instance( - cloud_provider=db_cloud_storage.provider_type, **details - ) - - tmp_dir = es.enter_context(tempfile.TemporaryDirectory(prefix="cvat")) - files_to_download = [] - checksums = [] - media = [] - for item in reader.iterate_frames(frame_ids): - file_name = f"{item['name']}{item['extension']}" - fs_filename = os.path.join(tmp_dir, file_name) - - files_to_download.append(file_name) - checksums.append(item.get("checksum", None)) - media.append((fs_filename, fs_filename, None)) - - cloud_storage_instance.bulk_download_to_dir( - files=files_to_download, upload_dir=tmp_dir - ) - media = preload_images(media) - - for checksum, (_, fs_filename, _) in zip(checksums, media): - if checksum and not md5_hash(fs_filename) == checksum: - slogger.cloud_storage[db_cloud_storage.id].warning( - "Hash sums of files {} do not match".format(file_name) - ) - - yield from media - else: - requested_frame_iter = iter(frame_ids) - next_requested_frame_id = next(requested_frame_iter, None) - if next_requested_frame_id is None: - return - - # TODO: find a way to use prefetched results, if provided - db_images = ( - db_data.images.order_by("frame") - .filter(frame__gte=frame_ids[0], frame__lte=frame_ids[-1]) - .values_list("frame", "path") - .all() - ) - - media = [] - for frame_id, frame_path in db_images: - if frame_id == next_requested_frame_id: - source_path = os.path.join(raw_data_dir, frame_path) - media.append((source_path, source_path, None)) - - next_requested_frame_id = next(requested_frame_iter, None) - - if next_requested_frame_id is None: - break - - assert next_requested_frame_id is None - - if dimension == models.DimensionType.DIM_2D: - media = preload_images(media) - - yield from media + return self._read_raw_images( + db_task, frame_ids, raw_data_dir=raw_data_dir, manifest_path=manifest_path + ) def prepare_segment_chunk( self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality From 7f5c7229981a98bc5be7f8e61029c8a69052d132 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 16 Aug 2024 14:57:00 +0300 Subject: [PATCH 073/227] Refactor some code --- cvat/apps/engine/cache.py | 7 +++++-- cvat/apps/engine/frame_provider.py | 4 +++- cvat/apps/engine/media_extractors.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index b2b0be9a1515..a15dd538528f 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -305,7 +305,7 @@ def _read_raw_frames( for frame_tuple in reader.iterate_frames(frame_filter=frame_ids): yield frame_tuple else: - return self._read_raw_images( + yield from self._read_raw_images( db_task, frame_ids, raw_data_dir=raw_data_dir, manifest_path=manifest_path ) @@ -506,6 +506,7 @@ def prepare_chunk( *, quality: FrameQuality, db_task: models.Task, + dump: bool = False, ) -> DataWithMime: # TODO: refactor all chunk building into another class @@ -534,7 +535,7 @@ def prepare_chunk( merged_chunk_writer = writer_class(image_quality, **writer_kwargs) writer_kwargs = {} - if isinstance(merged_chunk_writer, ZipCompressedChunkWriter): + if dump and isinstance(merged_chunk_writer, ZipCompressedChunkWriter): writer_kwargs = dict(compress_frames=False, zip_compress_level=1) buffer = io.BytesIO() @@ -547,6 +548,8 @@ def prepare_chunk( def get_chunk_mime_type_for_writer(writer: Union[IChunkWriter, Type[IChunkWriter]]) -> str: if isinstance(writer, IChunkWriter): writer_class = type(writer) + else: + writer_class = writer if issubclass(writer_class, ZipChunkWriter): return "application/zip" diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 28e6c396a764..c37489dda57a 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -312,7 +312,9 @@ def _set_callback() -> DataWithMime: ) 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) + return prepare_chunk( + task_chunk_frames.values(), quality=quality, db_task=self._db_task, dump=True + ) buffer, mime_type = cache.get_or_set_task_chunk( self._db_task, chunk_number, quality=quality, set_callback=_set_callback diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index afdeaba9efa6..1d5c6fde76e5 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -576,7 +576,7 @@ def __init__( source_path=source_path, step=step, start=start, - stop=stop if stop is not None else stop, + stop=stop, dimension=dimension, ) From daf4035e4a0456401e3433a38aa2e7d5a6e1e733 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 16 Aug 2024 14:59:42 +0300 Subject: [PATCH 074/227] Improve parameter name --- cvat/apps/engine/cache.py | 4 ++-- cvat/apps/engine/frame_provider.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index a15dd538528f..02c4ec4c5fb9 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -506,7 +506,7 @@ def prepare_chunk( *, quality: FrameQuality, db_task: models.Task, - dump: bool = False, + dump_unchanged: bool = False, ) -> DataWithMime: # TODO: refactor all chunk building into another class @@ -535,7 +535,7 @@ def prepare_chunk( merged_chunk_writer = writer_class(image_quality, **writer_kwargs) writer_kwargs = {} - if dump and isinstance(merged_chunk_writer, ZipCompressedChunkWriter): + if dump_unchanged and isinstance(merged_chunk_writer, ZipCompressedChunkWriter): writer_kwargs = dict(compress_frames=False, zip_compress_level=1) buffer = io.BytesIO() diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index c37489dda57a..77c92b6a040b 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -313,7 +313,7 @@ def _set_callback() -> DataWithMime: 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=True + task_chunk_frames.values(), quality=quality, db_task=self._db_task, dump_unchanged=True ) buffer, mime_type = cache.get_or_set_task_chunk( From 8c1b82c783931f3096af5a9c9cc85c3d4629f7b5 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 16 Aug 2024 15:05:13 +0300 Subject: [PATCH 075/227] Fix function call --- cvat/apps/engine/cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 02c4ec4c5fb9..4995c0466640 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -138,7 +138,7 @@ def get_or_set_task_chunk( ) -> DataWithMime: return self._get_or_set_cache_item( key=self._make_task_chunk_key(db_task, chunk_number, quality=quality), - create_callback=lambda: set_callback(db_task, chunk_number, quality=quality), + create_callback=set_callback, ) def get_selective_job_chunk( From f172865af2188a7a807a09a641adcf4fcb3e1921 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 16 Aug 2024 21:44:34 +0300 Subject: [PATCH 076/227] Add basic test set for meta, frames, and chunks reading in tasks --- cvat/apps/engine/frame_provider.py | 5 +- tests/python/rest_api/test_tasks.py | 294 ++++++++++++++++++++++++++- tests/python/shared/fixtures/init.py | 18 ++ tests/python/shared/utils/helpers.py | 24 ++- 4 files changed, 335 insertions(+), 6 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 77c92b6a040b..11ea5539e29d 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -313,7 +313,10 @@ def _set_callback() -> DataWithMime: 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 + task_chunk_frames.values(), + quality=quality, + db_task=self._db_task, + dump_unchanged=True, ) buffer, mime_type = cache.get_or_set_task_chunk( diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index f8f8510fdc79..f8d0f6951d79 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -6,10 +6,14 @@ import io import itertools import json +import math import os import os.path as osp import zipfile +from abc import ABCMeta, abstractmethod +from contextlib import closing from copy import deepcopy +from enum import Enum from functools import partial from http import HTTPStatus from itertools import chain, product @@ -18,8 +22,10 @@ from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory from time import sleep, time -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, ClassVar, Dict, Generator, List, Optional, Tuple +import attrs +import numpy as np import pytest from cvat_sdk import Client, Config, exceptions from cvat_sdk.api_client import models @@ -30,6 +36,7 @@ from cvat_sdk.core.uploading import Uploader from deepdiff import DeepDiff from PIL import Image +from pytest_cases import fixture_ref, parametrize import shared.utils.s3 as s3 from shared.fixtures.init import docker_exec_cvat, kube_exec_cvat @@ -48,6 +55,7 @@ generate_image_files, generate_manifest, generate_video_file, + read_video_file, ) from .utils import ( @@ -1968,6 +1976,290 @@ def test_create_task_with_cloud_storage_directories_and_default_bucket_prefix( assert task.size == expected_task_size +class _SourceDataType(str, Enum): + images = "images" + video = "video" + + +@pytest.mark.usefixtures("restore_db_per_class") +@pytest.mark.usefixtures("restore_redis_ondisk_per_class") +@pytest.mark.usefixtures("restore_cvat_data") +class TestTaskData: + _USERNAME = "admin1" + + class _TaskSpec(models.ITaskWriteRequest, models.IDataRequest, metaclass=ABCMeta): + size: int + frame_step: int + source_data_type: _SourceDataType + + @abstractmethod + def read_frame(self, i: int) -> Image.Image: ... + + @attrs.define + class _TaskSpecBase(_TaskSpec): + _params: dict | models.TaskWriteRequest + _data_params: dict | models.DataRequest + size: int = attrs.field(kw_only=True) + + @property + def frame_step(self) -> int: + v = getattr(self, "frame_filter", "step=1") + return int(v.split("=")[-1]) + + def __getattr__(self, k: str) -> Any: + notfound = object() + + for params in [self._params, self._data_params]: + if isinstance(params, dict): + v = params.get(k, notfound) + else: + v = getattr(params, k, notfound) + + if v is not notfound: + return v + + raise AttributeError(k) + + @attrs.define + class _ImagesTaskSpec(_TaskSpecBase): + source_data_type: ClassVar[_SourceDataType] = _SourceDataType.images + + _get_frame: Callable[[int], bytes] = attrs.field(kw_only=True) + + def read_frame(self, i: int) -> Image.Image: + return Image.open(io.BytesIO(self._get_frame(i))) + + @attrs.define + class _VideoTaskSpec(_TaskSpecBase): + source_data_type: ClassVar[_SourceDataType] = _SourceDataType.video + + _get_video_file: Callable[[], io.IOBase] = attrs.field(kw_only=True) + + def read_frame(self, i: int) -> Image.Image: + with closing(read_video_file(self._get_video_file())) as reader: + for _ in range(i + 1): + frame = next(reader) + + return frame + + def _uploaded_images_task_fxt_base( + self, + request: pytest.FixtureRequest, + *, + frame_count: int = 10, + segment_size: Optional[int] = None, + ) -> Generator[tuple[_TaskSpec, int], None, None]: + task_params = { + "name": request.node.name, + "labels": [{"name": "a"}], + } + if segment_size: + task_params["segment_size"] = segment_size + + image_files = generate_image_files(frame_count) + images_data = [f.getvalue() for f in image_files] + data_params = { + "image_quality": 70, + "client_files": image_files, + } + + def get_frame(i: int) -> bytes: + return images_data[i] + + task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params) + yield self._ImagesTaskSpec( + models.TaskWriteRequest._from_openapi_data(**task_params), + models.DataRequest._from_openapi_data(**data_params), + get_frame=get_frame, + size=len(images_data), + ), task_id + + @pytest.fixture(scope="class") + def fxt_uploaded_images_task( + self, request: pytest.FixtureRequest + ) -> Generator[tuple[_TaskSpec, int], None, None]: + yield from self._uploaded_images_task_fxt_base(request=request) + + @pytest.fixture(scope="class") + def fxt_uploaded_images_task_with_segments( + self, request: pytest.FixtureRequest + ) -> Generator[tuple[_TaskSpec, int], None, None]: + yield from self._uploaded_images_task_fxt_base(request=request, segment_size=4) + + def _uploaded_video_task_fxt_base( + self, + request: pytest.FixtureRequest, + *, + frame_count: int = 10, + segment_size: Optional[int] = None, + ) -> Generator[tuple[_TaskSpec, int], None, None]: + task_params = { + "name": request.node.name, + "labels": [{"name": "a"}], + } + if segment_size: + task_params["segment_size"] = segment_size + + video_file = generate_video_file(frame_count) + video_data = video_file.getvalue() + data_params = { + "image_quality": 70, + "client_files": [video_file], + } + + def get_video_file() -> io.BytesIO: + return io.BytesIO(video_data) + + task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params) + yield self._VideoTaskSpec( + models.TaskWriteRequest._from_openapi_data(**task_params), + models.DataRequest._from_openapi_data(**data_params), + get_video_file=get_video_file, + size=frame_count, + ), task_id + + @pytest.fixture(scope="class") + def fxt_uploaded_video_task( + self, + request: pytest.FixtureRequest, + ) -> Generator[tuple[_TaskSpec, int], None, None]: + yield from self._uploaded_video_task_fxt_base(request=request) + + @pytest.fixture(scope="class") + def fxt_uploaded_video_task_with_segments( + self, request: pytest.FixtureRequest + ) -> Generator[tuple[_TaskSpec, int], None, None]: + yield from self._uploaded_video_task_fxt_base(request=request, segment_size=4) + + _default_task_cases = [ + fixture_ref("fxt_uploaded_images_task"), + fixture_ref("fxt_uploaded_images_task_with_segments"), + fixture_ref("fxt_uploaded_video_task"), + fixture_ref("fxt_uploaded_video_task_with_segments"), + ] + + @parametrize("task_spec, task_id", _default_task_cases) + def test_can_get_task_meta(self, task_spec: _TaskSpec, task_id: int): + with make_api_client(self._USERNAME) as api_client: + (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) + + assert task_meta.size == task_spec.size + + assert task_meta.start_frame == getattr(task_spec, "start_frame", 0) + + if getattr(task_spec, "stop_frame", None): + assert task_meta.stop_frame == task_spec.stop_frame + + assert task_meta.frame_filter == getattr(task_spec, "frame_filter", "") + + task_frame_set = set( + range(task_meta.start_frame, task_meta.stop_frame + 1, task_spec.frame_step) + ) + assert len(task_frame_set) == task_meta.size + + if getattr(task_spec, "chunk_size", None): + assert task_meta.chunk_size == task_spec.chunk_size + + if task_spec.source_data_type == _SourceDataType.video: + assert len(task_meta.frames) == 1 + else: + assert len(task_meta.frames) == task_meta.size + + @parametrize("task_spec, task_id", _default_task_cases) + def test_can_get_task_frames(self, task_spec: _TaskSpec, task_id: int): + with make_api_client(self._USERNAME) as api_client: + (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) + + for quality, frame_id in product(["original", "compressed"], range(task_meta.size)): + (_, response) = api_client.tasks_api.retrieve_data( + task_id, + type="frame", + quality=quality, + number=frame_id, + _parse_response=False, + ) + + if task_spec.source_data_type == _SourceDataType.video: + frame_size = (task_meta.frames[0].height, task_meta.frames[0].width) + else: + frame_size = ( + task_meta.frames[frame_id].height, + task_meta.frames[frame_id].width, + ) + + expected_pixels = np.array(task_spec.read_frame(frame_id)) + assert frame_size == expected_pixels.shape[:2] + + frame_pixels = np.array(Image.open(io.BytesIO(response.data))) + assert frame_size == frame_pixels.shape[:2] + + if ( + quality == "original" + and task_spec.source_data_type == _SourceDataType.images + # video chunks can have slightly changed colors, due to codec specifics + ): + assert np.array_equal(frame_pixels, expected_pixels) + + @parametrize("task_spec, task_id", _default_task_cases) + def test_can_get_task_chunks(self, task_spec: _TaskSpec, task_id: int): + with make_api_client(self._USERNAME) as api_client: + (task, _) = api_client.tasks_api.retrieve(task_id) + (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) + + if task_spec.source_data_type == _SourceDataType.images: + assert task.data_original_chunk_type == "imageset" + assert task.data_compressed_chunk_type == "imageset" + elif task_spec.source_data_type == _SourceDataType.video: + assert task.data_original_chunk_type == "video" + + if getattr(task_spec, "use_zip_chunks", False): + assert task.data_compressed_chunk_type == "imageset" + else: + assert task.data_compressed_chunk_type == "video" + else: + assert False + + chunk_count = math.ceil(task_meta.size / task_meta.chunk_size) + for quality, chunk_id in product(["original", "compressed"], range(chunk_count)): + expected_chunk_frame_ids = range( + chunk_id * task_meta.chunk_size, + min((chunk_id + 1) * task_meta.chunk_size, task_meta.size), + ) + + (_, response) = api_client.tasks_api.retrieve_data( + task_id, type="chunk", quality=quality, number=chunk_id, _parse_response=False + ) + + chunk_file = io.BytesIO(response.data) + if zipfile.is_zipfile(chunk_file): + with zipfile.ZipFile(chunk_file, "r") as chunk_archive: + chunk_images = { + int(os.path.splitext(name)[0]): np.array( + Image.open(io.BytesIO(chunk_archive.read(name))) + ) + for name in chunk_archive.namelist() + } + + else: + chunk_images = dict(enumerate(read_video_file(chunk_file))) + + assert sorted(chunk_images.keys()) == list( + v - chunk_id * task_meta.chunk_size for v in expected_chunk_frame_ids + ) + + for chunk_frame, frame_id in zip(chunk_images, expected_chunk_frame_ids): + expected_pixels = np.array(task_spec.read_frame(frame_id)) + chunk_frame_pixels = np.array(chunk_images[chunk_frame]) + assert expected_pixels.shape == chunk_frame_pixels.shape + + if ( + quality == "original" + and task_spec.source_data_type == _SourceDataType.images + # video chunks can have slightly changed colors, due to codec specifics + ): + assert np.array_equal(chunk_frame_pixels, expected_pixels) + + @pytest.mark.usefixtures("restore_db_per_function") class TestPatchTaskLabel: def _get_task_labels(self, pid, user, **kwargs) -> List[models.Label]: diff --git a/tests/python/shared/fixtures/init.py b/tests/python/shared/fixtures/init.py index 8e9d334f7a47..0b4a13f5ec99 100644 --- a/tests/python/shared/fixtures/init.py +++ b/tests/python/shared/fixtures/init.py @@ -592,6 +592,15 @@ def restore_redis_inmem_per_function(request): kube_restore_redis_inmem() +@pytest.fixture(scope="class") +def restore_redis_inmem_per_class(request): + platform = request.config.getoption("--platform") + if platform == "local": + docker_restore_redis_inmem() + else: + kube_restore_redis_inmem() + + @pytest.fixture(scope="function") def restore_redis_ondisk_per_function(request): platform = request.config.getoption("--platform") @@ -599,3 +608,12 @@ def restore_redis_ondisk_per_function(request): docker_restore_redis_ondisk() else: kube_restore_redis_ondisk() + + +@pytest.fixture(scope="class") +def restore_redis_ondisk_per_class(request): + platform = request.config.getoption("--platform") + if platform == "local": + docker_restore_redis_ondisk() + else: + kube_restore_redis_ondisk() diff --git a/tests/python/shared/utils/helpers.py b/tests/python/shared/utils/helpers.py index f336cb3f9111..2200fd7f4b22 100644 --- a/tests/python/shared/utils/helpers.py +++ b/tests/python/shared/utils/helpers.py @@ -1,10 +1,11 @@ -# Copyright (C) 2022 CVAT.ai Corporation +# Copyright (C) 2022-2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT import subprocess +from contextlib import closing from io import BytesIO -from typing import List, Optional +from typing import Generator, List, Optional import av import av.video.reformatter @@ -13,7 +14,7 @@ from shared.fixtures.init import get_server_image_tag -def generate_image_file(filename="image.png", size=(50, 50), color=(0, 0, 0)): +def generate_image_file(filename="image.png", size=(100, 50), color=(0, 0, 0)): f = BytesIO() f.name = filename image = Image.new("RGB", size=size, color=color) @@ -40,7 +41,7 @@ def generate_image_files( return images -def generate_video_file(num_frames: int, size=(50, 50)) -> BytesIO: +def generate_video_file(num_frames: int, size=(100, 50)) -> BytesIO: f = BytesIO() f.name = "video.avi" @@ -60,6 +61,21 @@ def generate_video_file(num_frames: int, size=(50, 50)) -> BytesIO: return f +def read_video_file(file: BytesIO) -> Generator[Image.Image, None, None]: + file.seek(0) + + with av.open(file) as container: + video_stream = container.streams.video[0] + + with ( + closing(video_stream.codec_context), # pyav has a memory leak in stream.close() + closing(container.demux(video_stream)) as demux_iter, + ): + for packet in demux_iter: + for frame in packet.decode(): + yield frame.to_image() + + def generate_manifest(path: str) -> None: command = [ "docker", From aacceeed1661b9e9fab9cb38382a708140f5e5bb Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 16 Aug 2024 21:53:00 +0300 Subject: [PATCH 077/227] Move class declaration for pylint compatibility --- tests/python/rest_api/test_tasks.py | 98 +++++++++++++++-------------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index f8d0f6951d79..e91562ab6c98 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -1981,66 +1981,70 @@ class _SourceDataType(str, Enum): video = "video" -@pytest.mark.usefixtures("restore_db_per_class") -@pytest.mark.usefixtures("restore_redis_ondisk_per_class") -@pytest.mark.usefixtures("restore_cvat_data") -class TestTaskData: - _USERNAME = "admin1" +class _TaskSpec(models.ITaskWriteRequest, models.IDataRequest, metaclass=ABCMeta): + size: int + frame_step: int + source_data_type: _SourceDataType - class _TaskSpec(models.ITaskWriteRequest, models.IDataRequest, metaclass=ABCMeta): - size: int - frame_step: int - source_data_type: _SourceDataType + @abstractmethod + def read_frame(self, i: int) -> Image.Image: ... - @abstractmethod - def read_frame(self, i: int) -> Image.Image: ... - @attrs.define - class _TaskSpecBase(_TaskSpec): - _params: dict | models.TaskWriteRequest - _data_params: dict | models.DataRequest - size: int = attrs.field(kw_only=True) +@attrs.define +class _TaskSpecBase(_TaskSpec): + _params: dict | models.TaskWriteRequest + _data_params: dict | models.DataRequest + size: int = attrs.field(kw_only=True) - @property - def frame_step(self) -> int: - v = getattr(self, "frame_filter", "step=1") - return int(v.split("=")[-1]) + @property + def frame_step(self) -> int: + v = getattr(self, "frame_filter", "step=1") + return int(v.split("=")[-1]) - def __getattr__(self, k: str) -> Any: - notfound = object() + def __getattr__(self, k: str) -> Any: + notfound = object() - for params in [self._params, self._data_params]: - if isinstance(params, dict): - v = params.get(k, notfound) - else: - v = getattr(params, k, notfound) + for params in [self._params, self._data_params]: + if isinstance(params, dict): + v = params.get(k, notfound) + else: + v = getattr(params, k, notfound) + + if v is not notfound: + return v - if v is not notfound: - return v + raise AttributeError(k) - raise AttributeError(k) - @attrs.define - class _ImagesTaskSpec(_TaskSpecBase): - source_data_type: ClassVar[_SourceDataType] = _SourceDataType.images +@attrs.define +class _ImagesTaskSpec(_TaskSpecBase): + source_data_type: ClassVar[_SourceDataType] = _SourceDataType.images - _get_frame: Callable[[int], bytes] = attrs.field(kw_only=True) + _get_frame: Callable[[int], bytes] = attrs.field(kw_only=True) - def read_frame(self, i: int) -> Image.Image: - return Image.open(io.BytesIO(self._get_frame(i))) + def read_frame(self, i: int) -> Image.Image: + return Image.open(io.BytesIO(self._get_frame(i))) - @attrs.define - class _VideoTaskSpec(_TaskSpecBase): - source_data_type: ClassVar[_SourceDataType] = _SourceDataType.video - _get_video_file: Callable[[], io.IOBase] = attrs.field(kw_only=True) +@attrs.define +class _VideoTaskSpec(_TaskSpecBase): + source_data_type: ClassVar[_SourceDataType] = _SourceDataType.video - def read_frame(self, i: int) -> Image.Image: - with closing(read_video_file(self._get_video_file())) as reader: - for _ in range(i + 1): - frame = next(reader) + _get_video_file: Callable[[], io.IOBase] = attrs.field(kw_only=True) - return frame + def read_frame(self, i: int) -> Image.Image: + with closing(read_video_file(self._get_video_file())) as reader: + for _ in range(i + 1): + frame = next(reader) + + return frame + + +@pytest.mark.usefixtures("restore_db_per_class") +@pytest.mark.usefixtures("restore_redis_ondisk_per_class") +@pytest.mark.usefixtures("restore_cvat_data") +class TestTaskData: + _USERNAME = "admin1" def _uploaded_images_task_fxt_base( self, @@ -2067,7 +2071,7 @@ def get_frame(i: int) -> bytes: return images_data[i] task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params) - yield self._ImagesTaskSpec( + yield _ImagesTaskSpec( models.TaskWriteRequest._from_openapi_data(**task_params), models.DataRequest._from_openapi_data(**data_params), get_frame=get_frame, @@ -2111,7 +2115,7 @@ def get_video_file() -> io.BytesIO: return io.BytesIO(video_data) task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params) - yield self._VideoTaskSpec( + yield _VideoTaskSpec( models.TaskWriteRequest._from_openapi_data(**task_params), models.DataRequest._from_openapi_data(**data_params), get_video_file=get_video_file, From c8dbb7c938adf227f2d3678e055225c1e6b21fed Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 17 Aug 2024 00:46:27 +0300 Subject: [PATCH 078/227] Add missing original chunk type field in job responses --- cvat/apps/engine/serializers.py | 4 +++- cvat/schema.yml | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index d0177140e457..0db2e45ada81 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -594,6 +594,7 @@ class JobReadSerializer(serializers.ModelSerializer): dimension = serializers.CharField(max_length=2, source='segment.task.dimension', read_only=True) data_chunk_size = serializers.ReadOnlyField(source='segment.task.data.chunk_size') organization = serializers.ReadOnlyField(source='segment.task.organization.id', allow_null=True) + data_original_chunk_type = serializers.ReadOnlyField(source='segment.task.data.original_chunk_type') data_compressed_chunk_type = serializers.ReadOnlyField(source='segment.task.data.compressed_chunk_type') mode = serializers.ReadOnlyField(source='segment.task.mode') bug_tracker = serializers.CharField(max_length=2000, source='get_bug_tracker', @@ -607,7 +608,8 @@ class Meta: model = models.Job fields = ('url', 'id', 'task_id', 'project_id', 'assignee', 'guide_id', 'dimension', 'bug_tracker', 'status', 'stage', 'state', 'mode', 'frame_count', - 'start_frame', 'stop_frame', 'data_chunk_size', 'data_compressed_chunk_type', + 'start_frame', 'stop_frame', + 'data_chunk_size', 'data_compressed_chunk_type', 'data_original_chunk_type', 'created_date', 'updated_date', 'issues', 'labels', 'type', 'organization', 'target_storage', 'source_storage', 'assignee_updated_date') read_only_fields = fields diff --git a/cvat/schema.yml b/cvat/schema.yml index d4f01b9642ac..590dab4bc1e5 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -8064,6 +8064,10 @@ components: allOf: - $ref: '#/components/schemas/ChunkType' readOnly: true + data_original_chunk_type: + allOf: + - $ref: '#/components/schemas/ChunkType' + readOnly: true created_date: type: string format: date-time From 6b9a3e9f664831f2c37f49f5c9ced86e07d534ae Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 17 Aug 2024 01:53:24 +0300 Subject: [PATCH 079/227] Add tests for job data access --- tests/python/rest_api/test_tasks.py | 250 ++++++++++++++++++++++++---- 1 file changed, 216 insertions(+), 34 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index e91562ab6c98..a4c247ba93b6 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -22,7 +22,7 @@ from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory from time import sleep, time -from typing import Any, Callable, ClassVar, Dict, Generator, List, Optional, Tuple +from typing import Any, Callable, ClassVar, Dict, Generator, List, Optional, Tuple, cast import attrs import numpy as np @@ -2135,6 +2135,46 @@ def fxt_uploaded_video_task_with_segments( ) -> Generator[tuple[_TaskSpec, int], None, None]: yield from self._uploaded_video_task_fxt_base(request=request, segment_size=4) + def _compute_segment_params(self, task_spec: _TaskSpec) -> list[tuple[int, int]]: + segment_params = [] + segment_size = getattr(task_spec, "segment_size", 0) or task_spec.size + start_frame = getattr(task_spec, "start_frame", 0) + end_frame = (getattr(task_spec, "stop_frame", None) or (task_spec.size - 1)) + 1 + overlap = min( + ( + getattr(task_spec, "overlap", None) or 0 + if task_spec.source_data_type == _SourceDataType.images + else 5 + ), + segment_size // 2, + ) + segment_start = start_frame + while segment_start < end_frame: + if start_frame < segment_start: + segment_start -= overlap * task_spec.frame_step + + segment_end = segment_start + task_spec.frame_step * segment_size + + segment_params.append((segment_start, min(segment_end, end_frame) - 1)) + segment_start = segment_end + + return segment_params + + @staticmethod + def _compare_images( + expected: Image.Image, actual: Image.Image, *, must_be_identical: bool = True + ): + expected_pixels = np.array(expected) + chunk_frame_pixels = np.array(actual) + assert expected_pixels.shape == chunk_frame_pixels.shape + + if not must_be_identical: + # video chunks can have slightly changed colors, due to codec specifics + # compressed images can also be distorted + assert np.allclose(chunk_frame_pixels, expected_pixels, atol=2) + else: + assert np.array_equal(chunk_frame_pixels, expected_pixels) + _default_task_cases = [ fixture_ref("fxt_uploaded_images_task"), fixture_ref("fxt_uploaded_images_task_with_segments"), @@ -2148,12 +2188,8 @@ def test_can_get_task_meta(self, task_spec: _TaskSpec, task_id: int): (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) assert task_meta.size == task_spec.size - assert task_meta.start_frame == getattr(task_spec, "start_frame", 0) - - if getattr(task_spec, "stop_frame", None): - assert task_meta.stop_frame == task_spec.stop_frame - + assert task_meta.stop_frame == getattr(task_spec, "stop_frame", None) or task_spec.size assert task_meta.frame_filter == getattr(task_spec, "frame_filter", "") task_frame_set = set( @@ -2174,35 +2210,40 @@ def test_can_get_task_frames(self, task_spec: _TaskSpec, task_id: int): with make_api_client(self._USERNAME) as api_client: (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) - for quality, frame_id in product(["original", "compressed"], range(task_meta.size)): + for quality, abs_frame_id in product( + ["original", "compressed"], + range(task_meta.start_frame, task_meta.stop_frame + 1, task_spec.frame_step), + ): + rel_frame_id = ( + abs_frame_id - getattr(task_spec, "start_frame", 0) // task_spec.frame_step + ) (_, response) = api_client.tasks_api.retrieve_data( task_id, type="frame", quality=quality, - number=frame_id, + number=rel_frame_id, _parse_response=False, ) if task_spec.source_data_type == _SourceDataType.video: - frame_size = (task_meta.frames[0].height, task_meta.frames[0].width) + frame_size = (task_meta.frames[0].width, task_meta.frames[0].height) else: frame_size = ( - task_meta.frames[frame_id].height, - task_meta.frames[frame_id].width, + task_meta.frames[rel_frame_id].width, + task_meta.frames[rel_frame_id].height, ) - expected_pixels = np.array(task_spec.read_frame(frame_id)) - assert frame_size == expected_pixels.shape[:2] - - frame_pixels = np.array(Image.open(io.BytesIO(response.data))) - assert frame_size == frame_pixels.shape[:2] + frame = Image.open(io.BytesIO(response.data)) + assert frame_size == frame.size - if ( - quality == "original" - and task_spec.source_data_type == _SourceDataType.images - # video chunks can have slightly changed colors, due to codec specifics - ): - assert np.array_equal(frame_pixels, expected_pixels) + self._compare_images( + task_spec.read_frame(abs_frame_id), + frame, + must_be_identical=( + task_spec.source_data_type == _SourceDataType.images + and quality == "original" + ), + ) @parametrize("task_spec, task_id", _default_task_cases) def test_can_get_task_chunks(self, task_spec: _TaskSpec, task_id: int): @@ -2243,25 +2284,166 @@ def test_can_get_task_chunks(self, task_spec: _TaskSpec, task_id: int): ) for name in chunk_archive.namelist() } - + chunk_images = dict(sorted(chunk_images.items(), key=lambda e: e[0])) else: chunk_images = dict(enumerate(read_video_file(chunk_file))) - assert sorted(chunk_images.keys()) == list( - v - chunk_id * task_meta.chunk_size for v in expected_chunk_frame_ids + assert sorted(chunk_images.keys()) == list(range(len(expected_chunk_frame_ids))) + + for chunk_frame, abs_frame_id in zip(chunk_images, expected_chunk_frame_ids): + self._compare_images( + task_spec.read_frame(abs_frame_id), + chunk_images[chunk_frame], + must_be_identical=( + task_spec.source_data_type == _SourceDataType.images + and quality == "original" + ), + ) + + @parametrize("task_spec, task_id", _default_task_cases) + def test_can_get_job_meta(self, task_spec: _TaskSpec, task_id: int): + segment_params = self._compute_segment_params(task_spec) + with make_api_client(self._USERNAME) as api_client: + jobs = sorted( + get_paginated_collection(api_client.jobs_api.list_endpoint, task_id=task_id), + key=lambda j: j.start_frame, + ) + assert len(jobs) == len(segment_params) + + for (segment_start, segment_end), job in zip(segment_params, jobs): + (job_meta, _) = api_client.jobs_api.retrieve_data_meta(job.id) + + assert (job_meta.start_frame, job_meta.stop_frame) == (segment_start, segment_end) + assert job_meta.frame_filter == getattr(task_spec, "frame_filter", "") + + segment_size = segment_end - segment_start + 1 + assert job_meta.size == segment_size + + task_frame_set = set( + range(job_meta.start_frame, job_meta.stop_frame + 1, task_spec.frame_step) ) + assert len(task_frame_set) == job_meta.size + + if getattr(task_spec, "chunk_size", None): + assert job_meta.chunk_size == task_spec.chunk_size + + if task_spec.source_data_type == _SourceDataType.video: + assert len(job_meta.frames) == 1 + else: + assert len(job_meta.frames) == job_meta.size + + @parametrize("task_spec, task_id", _default_task_cases) + def test_can_get_job_frames(self, task_spec: _TaskSpec, task_id: int): + with make_api_client(self._USERNAME) as api_client: + jobs = sorted( + get_paginated_collection(api_client.jobs_api.list_endpoint, task_id=task_id), + key=lambda j: j.start_frame, + ) + for job in jobs: + (job_meta, _) = api_client.jobs_api.retrieve_data_meta(job.id) - for chunk_frame, frame_id in zip(chunk_images, expected_chunk_frame_ids): - expected_pixels = np.array(task_spec.read_frame(frame_id)) - chunk_frame_pixels = np.array(chunk_images[chunk_frame]) - assert expected_pixels.shape == chunk_frame_pixels.shape + for quality, (frame_pos, abs_frame_id) in product( + ["original", "compressed"], + enumerate(range(job_meta.start_frame, job_meta.stop_frame)), + ): + rel_frame_id = ( + abs_frame_id - getattr(task_spec, "start_frame", 0) // task_spec.frame_step + ) + (_, response) = api_client.jobs_api.retrieve_data( + job.id, + type="frame", + quality=quality, + number=rel_frame_id, + _parse_response=False, + ) - if ( - quality == "original" - and task_spec.source_data_type == _SourceDataType.images - # video chunks can have slightly changed colors, due to codec specifics + if task_spec.source_data_type == _SourceDataType.video: + frame_size = (job_meta.frames[0].width, job_meta.frames[0].height) + else: + frame_size = ( + job_meta.frames[frame_pos].width, + job_meta.frames[frame_pos].height, + ) + + frame = Image.open(io.BytesIO(response.data)) + assert frame_size == frame.size + + self._compare_images( + task_spec.read_frame(abs_frame_id), + frame, + must_be_identical=( + task_spec.source_data_type == _SourceDataType.images + and quality == "original" + ), + ) + + @parametrize("task_spec, task_id", _default_task_cases) + def test_can_get_job_chunks(self, task_spec: _TaskSpec, task_id: int): + with make_api_client(self._USERNAME) as api_client: + jobs = sorted( + get_paginated_collection(api_client.jobs_api.list_endpoint, task_id=task_id), + key=lambda j: j.start_frame, + ) + for job in jobs: + (job_meta, _) = api_client.jobs_api.retrieve_data_meta(job.id) + + if task_spec.source_data_type == _SourceDataType.images: + assert job.data_original_chunk_type == "imageset" + assert job.data_compressed_chunk_type == "imageset" + elif task_spec.source_data_type == _SourceDataType.video: + assert job.data_original_chunk_type == "video" + + if getattr(task_spec, "use_zip_chunks", False): + assert job.data_compressed_chunk_type == "imageset" + else: + assert job.data_compressed_chunk_type == "video" + else: + assert False + + chunk_count = math.ceil(job_meta.size / job_meta.chunk_size) + for quality, chunk_id in product(["original", "compressed"], range(chunk_count)): + expected_chunk_abs_frame_ids = range( + job_meta.start_frame + + chunk_id * job_meta.chunk_size * task_spec.frame_step, + job_meta.start_frame + + min((chunk_id + 1) * job_meta.chunk_size, job_meta.size) + * task_spec.frame_step, + ) + + (_, response) = api_client.jobs_api.retrieve_data( + job.id, + type="chunk", + quality=quality, + number=chunk_id, + _parse_response=False, + ) + + chunk_file = io.BytesIO(response.data) + if zipfile.is_zipfile(chunk_file): + with zipfile.ZipFile(chunk_file, "r") as chunk_archive: + chunk_images = { + int(os.path.splitext(name)[0]): np.array( + Image.open(io.BytesIO(chunk_archive.read(name))) + ) + for name in chunk_archive.namelist() + } + chunk_images = dict(sorted(chunk_images.items(), key=lambda e: e[0])) + else: + chunk_images = dict(enumerate(read_video_file(chunk_file))) + + assert sorted(chunk_images.keys()) == list(range(job_meta.size)) + + for chunk_frame, abs_frame_id in zip( + chunk_images, expected_chunk_abs_frame_ids ): - assert np.array_equal(chunk_frame_pixels, expected_pixels) + self._compare_images( + task_spec.read_frame(abs_frame_id), + chunk_images[chunk_frame], + must_be_identical=( + task_spec.source_data_type == _SourceDataType.images + and quality == "original" + ), + ) @pytest.mark.usefixtures("restore_db_per_function") From f5661e4bfd4e95634237a4d596ad2d02235a5563 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 17 Aug 2024 01:57:54 +0300 Subject: [PATCH 080/227] Update test assets --- tests/python/shared/assets/jobs.json | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/python/shared/assets/jobs.json b/tests/python/shared/assets/jobs.json index 82dd91cc0d97..9d26222e7acf 100644 --- a/tests/python/shared/assets/jobs.json +++ b/tests/python/shared/assets/jobs.json @@ -10,6 +10,7 @@ "created_date": "2024-07-15T15:34:53.594000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 1, "guide_id": null, @@ -51,6 +52,7 @@ "created_date": "2024-07-15T15:33:10.549000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 1, "guide_id": null, @@ -92,6 +94,7 @@ "created_date": "2024-03-21T20:50:05.838000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 3, "guide_id": null, @@ -125,6 +128,7 @@ "created_date": "2024-03-21T20:50:05.815000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 1, "guide_id": null, @@ -158,6 +162,7 @@ "created_date": "2024-03-21T20:50:05.811000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 5, "guide_id": null, @@ -191,6 +196,7 @@ "created_date": "2024-03-21T20:50:05.805000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 5, "guide_id": null, @@ -224,6 +230,7 @@ "created_date": "2023-05-26T16:11:23.946000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 3, "guide_id": null, @@ -257,6 +264,7 @@ "created_date": "2023-05-26T16:11:23.880000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 11, "guide_id": null, @@ -290,6 +298,7 @@ "created_date": "2023-03-27T19:08:07.649000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 4, "guide_id": null, @@ -331,6 +340,7 @@ "created_date": "2023-03-27T19:08:07.649000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 6, "guide_id": null, @@ -372,6 +382,7 @@ "created_date": "2023-03-10T11:57:31.614000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 2, "guide_id": null, @@ -413,6 +424,7 @@ "created_date": "2023-03-10T11:56:33.757000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 2, "guide_id": null, @@ -454,6 +466,7 @@ "created_date": "2023-03-01T15:36:26.668000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 2, "guide_id": null, @@ -495,6 +508,7 @@ "created_date": "2023-02-10T14:05:25.947000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 5, "guide_id": null, @@ -528,6 +542,7 @@ "created_date": "2022-12-01T12:53:10.425000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "video", "dimension": "2d", "frame_count": 25, "guide_id": null, @@ -569,6 +584,7 @@ "created_date": "2022-09-22T14:22:25.820000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 8, "guide_id": null, @@ -610,6 +626,7 @@ "created_date": "2022-06-08T08:33:06.505000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 5, "guide_id": null, @@ -649,6 +666,7 @@ "created_date": "2022-03-05T10:32:19.149000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 11, "guide_id": null, @@ -690,6 +708,7 @@ "created_date": "2022-03-05T09:33:10.420000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 5, "guide_id": null, @@ -723,6 +742,7 @@ "created_date": "2022-03-05T09:33:10.420000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 5, "guide_id": null, @@ -756,6 +776,7 @@ "created_date": "2022-03-05T09:33:10.420000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 5, "guide_id": null, @@ -795,6 +816,7 @@ "created_date": "2022-03-05T09:33:10.420000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 5, "guide_id": null, @@ -834,6 +856,7 @@ "created_date": "2022-03-05T08:30:48.612000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 14, "guide_id": null, @@ -867,6 +890,7 @@ "created_date": "2022-02-21T10:31:52.429000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 11, "guide_id": null, @@ -900,6 +924,7 @@ "created_date": "2022-02-16T06:26:54.631000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "3d", "frame_count": 1, "guide_id": null, @@ -939,6 +964,7 @@ "created_date": "2022-02-16T06:25:48.168000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "video", "dimension": "2d", "frame_count": 25, "guide_id": null, @@ -978,6 +1004,7 @@ "created_date": "2021-12-14T18:50:29.458000Z", "data_chunk_size": 72, "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", "dimension": "2d", "frame_count": 23, "guide_id": null, From 754757fd3cde40d389f0466b6fcb236b900d547d Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 17 Aug 2024 02:00:06 +0300 Subject: [PATCH 081/227] Clean imports --- tests/python/rest_api/test_tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index a4c247ba93b6..82a0c1c9bb54 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -22,7 +22,7 @@ from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory from time import sleep, time -from typing import Any, Callable, ClassVar, Dict, Generator, List, Optional, Tuple, cast +from typing import Any, Callable, ClassVar, Dict, Generator, List, Optional, Tuple import attrs import numpy as np From 0c001a52a0e1bf3663d6c7f8f796df5b7c47baa3 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 17 Aug 2024 02:30:13 +0300 Subject: [PATCH 082/227] Python 3.8 compatibility --- tests/python/shared/utils/helpers.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/python/shared/utils/helpers.py b/tests/python/shared/utils/helpers.py index 2200fd7f4b22..ac5948182d78 100644 --- a/tests/python/shared/utils/helpers.py +++ b/tests/python/shared/utils/helpers.py @@ -67,13 +67,11 @@ def read_video_file(file: BytesIO) -> Generator[Image.Image, None, None]: with av.open(file) as container: video_stream = container.streams.video[0] - with ( - closing(video_stream.codec_context), # pyav has a memory leak in stream.close() - closing(container.demux(video_stream)) as demux_iter, - ): - for packet in demux_iter: - for frame in packet.decode(): - yield frame.to_image() + with closing(video_stream.codec_context): # pyav has a memory leak in stream.close() + with closing(container.demux(video_stream)) as demux_iter: + for packet in demux_iter: + for frame in packet.decode(): + yield frame.to_image() def generate_manifest(path: str) -> None: From a9390eb67425b7610e00b0898d7558847e6a4bda Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 17 Aug 2024 12:24:33 +0300 Subject: [PATCH 083/227] Python 3.8 compatibility --- tests/python/rest_api/test_tasks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 82a0c1c9bb54..9e73b5aaaccd 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -22,7 +22,7 @@ from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory from time import sleep, time -from typing import Any, Callable, ClassVar, Dict, Generator, List, Optional, Tuple +from typing import Any, Callable, ClassVar, Dict, Generator, List, Optional, Tuple, Union import attrs import numpy as np @@ -1992,8 +1992,8 @@ def read_frame(self, i: int) -> Image.Image: ... @attrs.define class _TaskSpecBase(_TaskSpec): - _params: dict | models.TaskWriteRequest - _data_params: dict | models.DataRequest + _params: Union[dict, models.TaskWriteRequest] + _data_params: Union[dict, models.DataRequest] size: int = attrs.field(kw_only=True) @property From d2b138564f080371d62a96b27e401a9c8cc7fb69 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 17 Aug 2024 13:08:33 +0300 Subject: [PATCH 084/227] Python 3.8 compatibility --- tests/python/rest_api/test_tasks.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 9e73b5aaaccd..c66cdd2ed601 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -1992,8 +1992,8 @@ def read_frame(self, i: int) -> Image.Image: ... @attrs.define class _TaskSpecBase(_TaskSpec): - _params: Union[dict, models.TaskWriteRequest] - _data_params: Union[dict, models.DataRequest] + _params: Union[Dict, models.TaskWriteRequest] + _data_params: Union[Dict, models.DataRequest] size: int = attrs.field(kw_only=True) @property @@ -2052,7 +2052,7 @@ def _uploaded_images_task_fxt_base( *, frame_count: int = 10, segment_size: Optional[int] = None, - ) -> Generator[tuple[_TaskSpec, int], None, None]: + ) -> Generator[Tuple[_TaskSpec, int], None, None]: task_params = { "name": request.node.name, "labels": [{"name": "a"}], @@ -2081,13 +2081,13 @@ def get_frame(i: int) -> bytes: @pytest.fixture(scope="class") def fxt_uploaded_images_task( self, request: pytest.FixtureRequest - ) -> Generator[tuple[_TaskSpec, int], None, None]: + ) -> Generator[Tuple[_TaskSpec, int], None, None]: yield from self._uploaded_images_task_fxt_base(request=request) @pytest.fixture(scope="class") def fxt_uploaded_images_task_with_segments( self, request: pytest.FixtureRequest - ) -> Generator[tuple[_TaskSpec, int], None, None]: + ) -> Generator[Tuple[_TaskSpec, int], None, None]: yield from self._uploaded_images_task_fxt_base(request=request, segment_size=4) def _uploaded_video_task_fxt_base( @@ -2096,7 +2096,7 @@ def _uploaded_video_task_fxt_base( *, frame_count: int = 10, segment_size: Optional[int] = None, - ) -> Generator[tuple[_TaskSpec, int], None, None]: + ) -> Generator[Tuple[_TaskSpec, int], None, None]: task_params = { "name": request.node.name, "labels": [{"name": "a"}], @@ -2126,16 +2126,16 @@ def get_video_file() -> io.BytesIO: def fxt_uploaded_video_task( self, request: pytest.FixtureRequest, - ) -> Generator[tuple[_TaskSpec, int], None, None]: + ) -> Generator[Tuple[_TaskSpec, int], None, None]: yield from self._uploaded_video_task_fxt_base(request=request) @pytest.fixture(scope="class") def fxt_uploaded_video_task_with_segments( self, request: pytest.FixtureRequest - ) -> Generator[tuple[_TaskSpec, int], None, None]: + ) -> Generator[Tuple[_TaskSpec, int], None, None]: yield from self._uploaded_video_task_fxt_base(request=request, segment_size=4) - def _compute_segment_params(self, task_spec: _TaskSpec) -> list[tuple[int, int]]: + def _compute_segment_params(self, task_spec: _TaskSpec) -> List[Tuple[int, int]]: segment_params = [] segment_size = getattr(task_spec, "segment_size", 0) or task_spec.size start_frame = getattr(task_spec, "start_frame", 0) From 621afa7f4737842dccc2bf4ff19891c380bc7187 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 19 Aug 2024 17:10:30 +0300 Subject: [PATCH 085/227] Add logging into shell command runs, fix invalid redis-cli invocation for k8s deployment, add failures on redis-cli command failures --- tests/python/shared/fixtures/init.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/python/shared/fixtures/init.py b/tests/python/shared/fixtures/init.py index 0b4a13f5ec99..45fa543eb922 100644 --- a/tests/python/shared/fixtures/init.py +++ b/tests/python/shared/fixtures/init.py @@ -96,12 +96,20 @@ def pytest_addoption(parser): def _run(command, capture_output=True): _command = command.split() if isinstance(command, str) else command try: + logger.debug(f"Executing a command: {_command}") + stdout, stderr = "", "" if capture_output: proc = run(_command, check=True, stdout=PIPE, stderr=PIPE) # nosec stdout, stderr = proc.stdout.decode(), proc.stderr.decode() else: proc = run(_command) # nosec + + if stdout: + logger.debug(f"Output (stdout): {stdout}") + if stderr: + logger.debug(f"Output (stderr): {stderr}") + return stdout, stderr except CalledProcessError as exc: message = f"Command failed: {' '.join(map(shlex.quote, _command))}." @@ -232,20 +240,20 @@ def kube_restore_clickhouse_db(): def docker_restore_redis_inmem(): - docker_exec_redis_inmem(["redis-cli", "flushall"]) + docker_exec_redis_inmem(["redis-cli", "-e", "flushall"]) def kube_restore_redis_inmem(): - kube_exec_redis_inmem(["redis-cli", "flushall"]) + kube_exec_redis_inmem(["redis-cli", "-e", "flushall"]) def docker_restore_redis_ondisk(): - docker_exec_redis_ondisk(["redis-cli", "-p", "6666", "flushall"]) + docker_exec_redis_ondisk(["redis-cli", "-e", "-p", "6666", "flushall"]) def kube_restore_redis_ondisk(): kube_exec_redis_ondisk( - ["redis-cli", "-p", "6666", "-a", "${CVAT_REDIS_ONDISK_PASSWORD}", "flushall"] + ["sh", "-c", 'redis-cli -e -p 6666 -a "${CVAT_REDIS_ONDISK_PASSWORD}" flushall'] ) From c0f0d2f8f48db1fb5c612d65a5ab5a597ef66842 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 19 Aug 2024 20:23:47 +0300 Subject: [PATCH 086/227] Job validations - common --- cvat/apps/analytics_report/permissions.py | 12 ++---- cvat/apps/engine/permissions.py | 36 ++++++++++------ ...tyreport_assignee_last_updated_and_more.py | 42 +++++++++++++++++++ cvat/apps/quality_control/models.py | 24 +++++++++++ cvat/apps/quality_control/quality_reports.py | 4 ++ cvat/apps/quality_control/serializers.py | 21 +++++++++- cvat/apps/webhooks/permissions.py | 2 +- 7 files changed, 116 insertions(+), 25 deletions(-) create mode 100644 cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py diff --git a/cvat/apps/analytics_report/permissions.py b/cvat/apps/analytics_report/permissions.py index 9735fce22f14..118d1fe892cb 100644 --- a/cvat/apps/analytics_report/permissions.py +++ b/cvat/apps/analytics_report/permissions.py @@ -34,9 +34,7 @@ def create(cls, request, view, obj, iam_context): project_id = request.query_params.get("project_id", None) if job_id: - job = Job.objects.get(id=job_id) - iam_context = get_iam_context(request, job) - perm = JobPermission.create_scope_view(iam_context, int(job_id)) + perm = JobPermission.create_scope_view(request, int(job_id)) permissions.append(perm) else: job_id = request.data.get("job_id", None) @@ -48,15 +46,11 @@ def create(cls, request, view, obj, iam_context): task_id = job.segment.task.id if task_id: - task = Task.objects.get(id=task_id) - iam_context = get_iam_context(request, task) - perm = TaskPermission.create_scope_view(request, int(task_id), iam_context) + perm = TaskPermission.create_scope_view(request, int(task_id)) permissions.append(perm) if project_id: - project = Project.objects.get(id=project_id) - iam_context = get_iam_context(request, project) - perm = ProjectPermission.create_scope_view(iam_context, int(project_id)) + perm = ProjectPermission.create_scope_view(request, int(project_id)) permissions.append(perm) except ObjectDoesNotExist as ex: raise ValidationError( diff --git a/cvat/apps/engine/permissions.py b/cvat/apps/engine/permissions.py index 414d64882631..efa7d3f7b99f 100644 --- a/cvat/apps/engine/permissions.py +++ b/cvat/apps/engine/permissions.py @@ -305,12 +305,17 @@ def get_scopes(request, view, obj): return scopes @classmethod - def create_scope_view(cls, iam_context, project_id): - try: - obj = Project.objects.get(id=project_id) - except Project.DoesNotExist as ex: - raise ValidationError(str(ex)) - return cls(**iam_context, obj=obj, scope=__class__.Scopes.VIEW) + def create_scope_view(cls, request, project: Union[int, Project], iam_context=None): + if isinstance(project, int): + try: + project = Project.objects.get(id=project) + except Project.DoesNotExist as ex: + raise ValidationError(str(ex)) + + if not iam_context and request: + iam_context = get_iam_context(request, project) + + return cls(**iam_context, obj=project, scope=__class__.Scopes.VIEW) @classmethod def create_scope_create(cls, request, org_id): @@ -422,7 +427,7 @@ def create(cls, request, view, obj, iam_context): permissions.append(perm) if project_id: - perm = ProjectPermission.create_scope_view(iam_context, project_id) + perm = ProjectPermission.create_scope_view(request, int(project_id), iam_context) permissions.append(perm) for field_source, field in [ @@ -684,12 +689,17 @@ def create_scope_view_data(cls, iam_context, job_id): return cls(**iam_context, obj=obj, scope='view:data') @classmethod - def create_scope_view(cls, iam_context, job_id): - try: - obj = Job.objects.get(id=job_id) - except Job.DoesNotExist as ex: - raise ValidationError(str(ex)) - return cls(**iam_context, obj=obj, scope=__class__.Scopes.VIEW) + def create_scope_view(cls, request, job: Union[int, Job], iam_context=None): + if isinstance(job, int): + try: + job = Job.objects.get(id=job) + except Job.DoesNotExist as ex: + raise ValidationError(str(ex)) + + if not iam_context and request: + iam_context = get_iam_context(request, job) + + return cls(**iam_context, obj=job, scope=__class__.Scopes.VIEW) def __init__(self, **kwargs): self.task_id = kwargs.pop('task_id', None) diff --git a/cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py b/cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py new file mode 100644 index 000000000000..529f52f0d375 --- /dev/null +++ b/cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.14 on 2024-08-19 17:23 + +import cvat.apps.quality_control.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("quality_control", "0002_qualityreport_assignee"), + ] + + operations = [ + migrations.AddField( + model_name="qualityreport", + name="assignee_last_updated", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="qualitysettings", + name="max_validations_per_job", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="qualitysettings", + name="target_metric", + field=models.CharField( + choices=[ + ("accuracy", "ACCURACY"), + ("precision", "PRECISION"), + ("recall", "RECALL"), + ], + default=cvat.apps.quality_control.models.QualityTargetMetricType["ACCURACY"], + max_length=32, + ), + ), + migrations.AddField( + model_name="qualitysettings", + name="target_metric_threshold", + field=models.FloatField(default=0.7), + ), + ] diff --git a/cvat/apps/quality_control/models.py b/cvat/apps/quality_control/models.py index c92e677479e8..6dc2f9384d0d 100644 --- a/cvat/apps/quality_control/models.py +++ b/cvat/apps/quality_control/models.py @@ -69,6 +69,19 @@ def choices(cls): return tuple((x.value, x.name) for x in cls) +class QualityTargetMetricType(str, Enum): + ACCURACY = "accuracy" + PRECISION = "precision" + RECALL = "recall" + + def __str__(self) -> str: + return self.value + + @classmethod + def choices(cls): + return tuple((x.value, x.name) for x in cls) + + class QualityReport(models.Model): job = models.ForeignKey( Job, on_delete=models.CASCADE, related_name="quality_reports", null=True, blank=True @@ -89,6 +102,7 @@ class QualityReport(models.Model): assignee = models.ForeignKey( User, on_delete=models.SET_NULL, related_name="quality_reports", null=True, blank=True ) + assignee_last_updated = models.DateTimeField(null=True) data = models.JSONField() @@ -204,6 +218,16 @@ class QualitySettings(models.Model): compare_attributes = models.BooleanField() + target_metric = models.CharField( + max_length=32, + choices=QualityTargetMetricType.choices(), + default=QualityTargetMetricType.ACCURACY, + ) + + target_metric_threshold = models.FloatField(default=0.7) + + max_validations_per_job = models.IntegerField(default=0) + def __init__(self, *args: Any, **kwargs: Any) -> None: defaults = deepcopy(self.get_defaults()) for field in self._meta.fields: diff --git a/cvat/apps/quality_control/quality_reports.py b/cvat/apps/quality_control/quality_reports.py index edc4264d380e..a62e46f52ccd 100644 --- a/cvat/apps/quality_control/quality_reports.py +++ b/cvat/apps/quality_control/quality_reports.py @@ -2302,6 +2302,7 @@ def _compute_reports(self, task_id: int) -> int: target_last_updated=job.updated_date, gt_last_updated=gt_job.updated_date, assignee_id=job.assignee_id, + assignee_last_updated=job.assignee_updated_date, data=job_comparison_report.to_json(), conflicts=[c.to_dict() for c in job_comparison_report.conflicts], ) @@ -2314,6 +2315,7 @@ def _compute_reports(self, task_id: int) -> int: target_last_updated=task.updated_date, gt_last_updated=gt_job.updated_date, assignee_id=task.assignee_id, + assignee_last_updated=task.assignee_updated_date, data=task_comparison_report.to_json(), conflicts=[], # the task doesn't have own conflicts ), @@ -2420,6 +2422,7 @@ def _save_reports(self, *, task_report: Dict, job_reports: List[Dict]) -> models target_last_updated=task_report["target_last_updated"], gt_last_updated=task_report["gt_last_updated"], assignee_id=task_report["assignee_id"], + assignee_last_updated=task_report["assignee_last_updated"], data=task_report["data"], ) db_task_report.save() @@ -2432,6 +2435,7 @@ def _save_reports(self, *, task_report: Dict, job_reports: List[Dict]) -> models target_last_updated=job_report["target_last_updated"], gt_last_updated=job_report["gt_last_updated"], assignee_id=job_report["assignee_id"], + assignee_last_updated=job_report["assignee_last_updated"], data=job_report["data"], ) db_job_reports.append(db_job_report) diff --git a/cvat/apps/quality_control/serializers.py b/cvat/apps/quality_control/serializers.py index 2ebe8f5a8c72..0688a4f9e6a0 100644 --- a/cvat/apps/quality_control/serializers.py +++ b/cvat/apps/quality_control/serializers.py @@ -34,13 +34,15 @@ class QualityReportSummarySerializer(serializers.Serializer): error_count = serializers.IntegerField() conflicts_by_type = serializers.DictField(child=serializers.IntegerField()) - # This set is enough for basic characteristics, such as - # DS_unmatched, GT_unmatched, accuracy, precision and recall valid_count = serializers.IntegerField(source="annotations.valid_count") ds_count = serializers.IntegerField(source="annotations.ds_count") gt_count = serializers.IntegerField(source="annotations.gt_count") total_count = serializers.IntegerField(source="annotations.total_count") + accuracy = serializers.FloatField(source="annotations.accuracy") + precision = serializers.FloatField(source="annotations.precision") + recall = serializers.FloatField(source="annotations.recall") + class QualityReportSerializer(serializers.ModelSerializer): target = serializers.ChoiceField(models.QualityReportTarget.choices()) @@ -74,6 +76,9 @@ class Meta: fields = ( "id", "task_id", + "target_metric", + "target_metric_threshold", + "max_validations_per_job", "iou_threshold", "oks_sigma", "line_thickness", @@ -95,6 +100,15 @@ class Meta: extra_kwargs = {k: {"required": False} for k in fields} for field_name, help_text in { + "target_metric": "The primary metric used for quality estimation", + "target_metric_threshold": """ + Defines the minimal quality requirements in terms of the selected target metric. + """, + "max_validations_per_job": """ + The maximum number of job validation attempts for the job assignee. + The job can be automatically accepted if the job quality is above the required + threshold, defined by the target threshold parameter. + """, "iou_threshold": "Used for distinction between matched / unmatched shapes", "low_overlap_threshold": """ Used for distinction between strong / weak (low_overlap) matches @@ -144,4 +158,7 @@ def validate(self, attrs): if not 0 <= v <= 1: raise serializers.ValidationError(f"{k} must be in the range [0; 1]") + if (max_validations := attrs.get("max_validations_per_job")) and max_validations < 0: + raise serializers.ValidationError("max_validations_per_job cannot be less than 0") + return super().validate(attrs) diff --git a/cvat/apps/webhooks/permissions.py b/cvat/apps/webhooks/permissions.py index 40b4d3ebfd2f..d2ffd87b395b 100644 --- a/cvat/apps/webhooks/permissions.py +++ b/cvat/apps/webhooks/permissions.py @@ -39,7 +39,7 @@ def create(cls, request, view, obj, iam_context): permissions.append(perm) if project_id: - perm = ProjectPermission.create_scope_view(iam_context, project_id) + perm = ProjectPermission.create_scope_view(request, project_id, iam_context) permissions.append(perm) return permissions From da2d9fb4302a6807f84288123b60ab3010394090 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 19 Aug 2024 21:07:49 +0300 Subject: [PATCH 087/227] Update changelog --- changelog.d/20240819_210200_mzhiltso_validation_api.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/20240819_210200_mzhiltso_validation_api.md diff --git a/changelog.d/20240819_210200_mzhiltso_validation_api.md b/changelog.d/20240819_210200_mzhiltso_validation_api.md new file mode 100644 index 000000000000..2031da1456ab --- /dev/null +++ b/changelog.d/20240819_210200_mzhiltso_validation_api.md @@ -0,0 +1,4 @@ +### Added + +- Last assignee update date in quality reports, new options in quality settings + () From 65b459174403b65eb9e234f07b73b3fc4e521a7f Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 19 Aug 2024 21:09:46 +0300 Subject: [PATCH 088/227] Remove unused imports --- cvat/apps/analytics_report/permissions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat/apps/analytics_report/permissions.py b/cvat/apps/analytics_report/permissions.py index 118d1fe892cb..e6177b1d4df8 100644 --- a/cvat/apps/analytics_report/permissions.py +++ b/cvat/apps/analytics_report/permissions.py @@ -7,9 +7,9 @@ from django.core.exceptions import ObjectDoesNotExist from rest_framework.exceptions import ValidationError -from cvat.apps.engine.models import Job, Project, Task +from cvat.apps.engine.models import Job from cvat.apps.engine.permissions import JobPermission, ProjectPermission, TaskPermission -from cvat.apps.iam.permissions import OpenPolicyAgentPermission, StrEnum, get_iam_context +from cvat.apps.iam.permissions import OpenPolicyAgentPermission, StrEnum class AnalyticsReportPermission(OpenPolicyAgentPermission): From c5fa228c2ba1861a906c01ecef8a236adbcaa0bd Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 19 Aug 2024 23:19:29 +0300 Subject: [PATCH 089/227] Update test assets --- tests/python/shared/assets/cvat_db/data.json | 112 ++++++++++++++---- .../python/shared/assets/quality_reports.json | 36 ++++++ .../shared/assets/quality_settings.json | 60 ++++++++++ 3 files changed, 188 insertions(+), 20 deletions(-) diff --git a/tests/python/shared/assets/cvat_db/data.json b/tests/python/shared/assets/cvat_db/data.json index 39b63b2c60b1..064f6e707419 100644 --- a/tests/python/shared/assets/cvat_db/data.json +++ b/tests/python/shared/assets/cvat_db/data.json @@ -11864,6 +11864,7 @@ "target_last_updated": "2023-05-26T16:17:02.635Z", "gt_last_updated": "2023-05-26T16:16:50.630Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.2727272727272727,\"frames\":[0,1,2],\"conflict_count\":37,\"warning_count\":15,\"error_count\":22,\"conflicts_by_type\":{\"low_overlap\":6,\"missing_annotation\":9,\"extra_annotation\":10,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6},\"annotations\":{\"valid_count\":21,\"missing_count\":9,\"extra_count\":10,\"total_count\":43,\"ds_count\":34,\"gt_count\":33,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"unmatched\"],\"rows\":[[0,2,3],[1,21,7],[1,8,0]],\"precision\":[0.0,0.7241379310344828,0.0],\"recall\":[0.0,0.6774193548387096,0.0],\"accuracy\":[0.0,0.5384615384615384,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.4883720930232558,\"precision\":0.6176470588235294,\"recall\":0.6363636363636364},\"annotation_components\":{\"shape\":{\"valid_count\":24,\"missing_count\":9,\"extra_count\":10,\"total_count\":43,\"ds_count\":34,\"gt_count\":33,\"mean_iou\":0.19532955159399454,\"accuracy\":0.5581395348837209},\"label\":{\"valid_count\":21,\"invalid_count\":3,\"total_count\":24,\"accuracy\":0.875}},\"frame_count\":3,\"mean_conflict_count\":12.333333333333334},\"frame_results\":{\"0\":{\"conflicts\":[{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":91,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":118,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":88,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":102,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":68,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"},{\"obj_id\":98,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":107,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":121,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":122,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":70,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":76,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":93,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":74,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":95,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":73,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":96,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":64,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":66,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":94,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":89,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":123,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"},{\"obj_id\":97,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_direction\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"covered_annotation\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_attributes\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":82,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":103,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":81,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":104,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":83,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":105,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":87,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":106,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":80,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":111,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":77,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":114,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"}],\"annotations\":{\"valid_count\":21,\"missing_count\":3,\"extra_count\":10,\"total_count\":37,\"ds_count\":34,\"gt_count\":27,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"unmatched\"],\"rows\":[[0,2,3],[1,21,7],[1,2,0]],\"precision\":[0.0,0.7241379310344828,0.0],\"recall\":[0.0,0.84,0.0],\"accuracy\":[0.0,0.6363636363636364,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.5675675675675675,\"precision\":0.6176470588235294,\"recall\":0.7777777777777778},\"annotation_components\":{\"shape\":{\"valid_count\":24,\"missing_count\":3,\"extra_count\":10,\"total_count\":37,\"ds_count\":34,\"gt_count\":27,\"mean_iou\":0.5859886547819836,\"accuracy\":0.6486486486486487},\"label\":{\"valid_count\":21,\"invalid_count\":3,\"total_count\":24,\"accuracy\":0.875}},\"conflict_count\":31,\"warning_count\":15,\"error_count\":16,\"conflicts_by_type\":{\"low_overlap\":6,\"missing_annotation\":3,\"extra_annotation\":10,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6}},\"1\":{\"conflicts\":[{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":130,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":128,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":124,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":125,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":127,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":129,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"}],\"annotations\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"unmatched\"],\"rows\":[[0,0,0],[0,0,0],[0,6,0]],\"precision\":[0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"mean_iou\":0.0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":6,\"warning_count\":0,\"error_count\":6,\"conflicts_by_type\":{\"missing_annotation\":6}},\"2\":{\"conflicts\":[],\"annotations\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"unmatched\"],\"rows\":[[0,0,0],[0,0,0],[0,0,0]],\"precision\":[0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"mean_iou\":0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":0,\"warning_count\":0,\"error_count\":0,\"conflicts_by_type\":{}}}}" } }, @@ -11878,6 +11879,7 @@ "target_last_updated": "2023-05-26T16:11:24.294Z", "gt_last_updated": "2023-05-26T16:16:50.630Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.2727272727272727,\"frames\":[0,1,2],\"conflict_count\":37,\"warning_count\":15,\"error_count\":22,\"conflicts_by_type\":{\"low_overlap\":6,\"missing_annotation\":9,\"extra_annotation\":10,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6},\"annotations\":{\"valid_count\":21,\"missing_count\":9,\"extra_count\":10,\"total_count\":43,\"ds_count\":34,\"gt_count\":33,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"unmatched\"],\"rows\":[[0,2,3],[1,21,7],[1,8,0]],\"precision\":[0.0,0.7241379310344828,0.0],\"recall\":[0.0,0.6774193548387096,0.0],\"accuracy\":[0.0,0.5384615384615384,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.4883720930232558,\"precision\":0.6176470588235294,\"recall\":0.6363636363636364},\"annotation_components\":{\"shape\":{\"valid_count\":24,\"missing_count\":9,\"extra_count\":10,\"total_count\":43,\"ds_count\":34,\"gt_count\":33,\"mean_iou\":0.19532955159399454,\"accuracy\":0.5581395348837209},\"label\":{\"valid_count\":21,\"invalid_count\":3,\"total_count\":24,\"accuracy\":0.875}},\"frame_count\":3,\"mean_conflict_count\":12.333333333333334},\"frame_results\":{\"0\":{\"conflicts\":[{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":91,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":118,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":88,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":102,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":68,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"},{\"obj_id\":98,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":107,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":121,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":122,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":70,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":76,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":93,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":74,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":95,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":73,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":96,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":64,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":66,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":94,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":89,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":123,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"},{\"obj_id\":97,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_direction\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"covered_annotation\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_attributes\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":82,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":103,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":81,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":104,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":83,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":105,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":87,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":106,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":80,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":111,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":77,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":114,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"}],\"annotations\":{\"valid_count\":21,\"missing_count\":3,\"extra_count\":10,\"total_count\":37,\"ds_count\":34,\"gt_count\":27,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"unmatched\"],\"rows\":[[0,2,3],[1,21,7],[1,2,0]],\"precision\":[0.0,0.7241379310344828,0.0],\"recall\":[0.0,0.84,0.0],\"accuracy\":[0.0,0.6363636363636364,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.5675675675675675,\"precision\":0.6176470588235294,\"recall\":0.7777777777777778},\"annotation_components\":{\"shape\":{\"valid_count\":24,\"missing_count\":3,\"extra_count\":10,\"total_count\":37,\"ds_count\":34,\"gt_count\":27,\"mean_iou\":0.5859886547819836,\"accuracy\":0.6486486486486487},\"label\":{\"valid_count\":21,\"invalid_count\":3,\"total_count\":24,\"accuracy\":0.875}},\"conflict_count\":31,\"warning_count\":15,\"error_count\":16,\"conflicts_by_type\":{\"low_overlap\":6,\"missing_annotation\":3,\"extra_annotation\":10,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6}},\"1\":{\"conflicts\":[{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":130,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":128,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":124,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":125,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":127,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":129,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"}],\"annotations\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"unmatched\"],\"rows\":[[0,0,0],[0,0,0],[0,6,0]],\"precision\":[0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"mean_iou\":0.0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":6,\"warning_count\":0,\"error_count\":6,\"conflicts_by_type\":{\"missing_annotation\":6}},\"2\":{\"conflicts\":[],\"annotations\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"unmatched\"],\"rows\":[[0,0,0],[0,0,0],[0,0,0]],\"precision\":[0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"mean_iou\":0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":0,\"warning_count\":0,\"error_count\":0,\"conflicts_by_type\":{}}}}" } }, @@ -11892,6 +11894,7 @@ "target_last_updated": "2023-11-24T15:23:30.045Z", "gt_last_updated": "2023-11-24T15:18:55.216Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.2727272727272727,\"frames\":[0,1,2],\"conflict_count\":40,\"warning_count\":16,\"error_count\":24,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":10,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6},\"annotations\":{\"valid_count\":22,\"missing_count\":10,\"extra_count\":11,\"total_count\":46,\"ds_count\":36,\"gt_count\":35,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,8,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.6774193548387096,0.5,0.0],\"accuracy\":[0.0,0.5384615384615384,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.4782608695652174,\"precision\":0.6111111111111112,\"recall\":0.6285714285714286},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":10,\"extra_count\":11,\"total_count\":46,\"ds_count\":36,\"gt_count\":35,\"mean_iou\":0.18567508032031,\"accuracy\":0.5434782608695652},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"frame_count\":3,\"mean_conflict_count\":13.333333333333334},\"frame_results\":{\"0\":{\"conflicts\":[{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":91,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":118,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":88,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":102,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":68,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"},{\"obj_id\":98,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":141,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"},{\"obj_id\":132,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":107,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":121,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":122,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":131,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":70,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":76,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":93,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":74,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":95,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":73,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":96,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":64,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":66,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":94,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":142,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":89,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":123,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"},{\"obj_id\":97,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_direction\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"covered_annotation\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_attributes\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":82,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":103,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":81,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":104,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":83,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":105,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":87,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":106,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":80,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":111,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":77,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":114,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"}],\"annotations\":{\"valid_count\":22,\"missing_count\":4,\"extra_count\":11,\"total_count\":40,\"ds_count\":36,\"gt_count\":29,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,2,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.84,0.5,0.0],\"accuracy\":[0.0,0.6363636363636364,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.55,\"precision\":0.6111111111111112,\"recall\":0.7586206896551724},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":4,\"extra_count\":11,\"total_count\":40,\"ds_count\":36,\"gt_count\":29,\"mean_iou\":0.55702524096093,\"accuracy\":0.625},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"conflict_count\":34,\"warning_count\":16,\"error_count\":18,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":4,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6}},\"1\":{\"conflicts\":[{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":130,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":128,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":124,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":125,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":127,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":129,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"}],\"annotations\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,6,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"mean_iou\":0.0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":6,\"warning_count\":0,\"error_count\":6,\"conflicts_by_type\":{\"missing_annotation\":6}},\"2\":{\"conflicts\":[],\"annotations\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"mean_iou\":0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":0,\"warning_count\":0,\"error_count\":0,\"conflicts_by_type\":{}}}}" } }, @@ -11906,6 +11909,7 @@ "target_last_updated": "2023-11-24T15:23:30.269Z", "gt_last_updated": "2023-11-24T15:18:55.216Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.2727272727272727,\"frames\":[0,1,2],\"conflict_count\":40,\"warning_count\":16,\"error_count\":24,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":10,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6},\"annotations\":{\"valid_count\":22,\"missing_count\":10,\"extra_count\":11,\"total_count\":46,\"ds_count\":36,\"gt_count\":35,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,8,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.6774193548387096,0.5,0.0],\"accuracy\":[0.0,0.5384615384615384,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.4782608695652174,\"precision\":0.6111111111111112,\"recall\":0.6285714285714286},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":10,\"extra_count\":11,\"total_count\":46,\"ds_count\":36,\"gt_count\":35,\"mean_iou\":0.18567508032031,\"accuracy\":0.5434782608695652},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"frame_count\":3,\"mean_conflict_count\":13.333333333333334},\"frame_results\":{\"0\":{\"conflicts\":[{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":91,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":118,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":88,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":102,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":68,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"},{\"obj_id\":98,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":141,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"},{\"obj_id\":132,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":107,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":121,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":122,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":131,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":70,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":76,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":93,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":74,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":95,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":73,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":96,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":64,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":66,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":94,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":142,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":89,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":123,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"},{\"obj_id\":97,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_direction\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"covered_annotation\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_attributes\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":82,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":103,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":81,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":104,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":83,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":105,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":87,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":106,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":80,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":111,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":77,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":114,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"}],\"annotations\":{\"valid_count\":22,\"missing_count\":4,\"extra_count\":11,\"total_count\":40,\"ds_count\":36,\"gt_count\":29,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,2,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.84,0.5,0.0],\"accuracy\":[0.0,0.6363636363636364,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.55,\"precision\":0.6111111111111112,\"recall\":0.7586206896551724},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":4,\"extra_count\":11,\"total_count\":40,\"ds_count\":36,\"gt_count\":29,\"mean_iou\":0.55702524096093,\"accuracy\":0.625},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"conflict_count\":34,\"warning_count\":16,\"error_count\":18,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":4,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6}},\"1\":{\"conflicts\":[{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":130,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":128,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":124,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":125,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":127,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":129,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"}],\"annotations\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,6,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"mean_iou\":0.0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":6,\"warning_count\":0,\"error_count\":6,\"conflicts_by_type\":{\"missing_annotation\":6}},\"2\":{\"conflicts\":[],\"annotations\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"mean_iou\":0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":0,\"warning_count\":0,\"error_count\":0,\"conflicts_by_type\":{}}}}" } }, @@ -11920,6 +11924,7 @@ "target_last_updated": "2023-11-24T15:23:30.045Z", "gt_last_updated": "2023-11-24T15:18:55.216Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.2727272727272727,\"frames\":[0,1,2],\"conflict_count\":40,\"warning_count\":16,\"error_count\":24,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":10,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6},\"annotations\":{\"valid_count\":22,\"missing_count\":10,\"extra_count\":11,\"total_count\":46,\"ds_count\":36,\"gt_count\":35,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,8,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.6774193548387096,0.5,0.0],\"accuracy\":[0.8478260869565217,0.6086956521739131,0.9565217391304348,0.5434782608695652],\"jaccard_index\":[0.0,0.5384615384615384,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.4782608695652174,\"precision\":0.6111111111111112,\"recall\":0.6285714285714286},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":10,\"extra_count\":11,\"total_count\":46,\"ds_count\":36,\"gt_count\":35,\"mean_iou\":0.18567508032031,\"accuracy\":0.5434782608695652},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"frame_count\":3,\"mean_conflict_count\":13.333333333333334},\"frame_results\":{\"0\":{\"conflicts\":[{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":91,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":118,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":88,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":102,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":68,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"},{\"obj_id\":98,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":141,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"},{\"obj_id\":132,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":107,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":121,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":122,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":131,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":70,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":76,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":93,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":74,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":95,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":73,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":96,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":64,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":66,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":94,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":142,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":89,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":123,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"},{\"obj_id\":97,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_direction\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"covered_annotation\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_attributes\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":82,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":103,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":81,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":104,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":83,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":105,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":87,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":106,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":80,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":111,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":77,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":114,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"}],\"annotations\":{\"valid_count\":22,\"missing_count\":4,\"extra_count\":11,\"total_count\":40,\"ds_count\":36,\"gt_count\":29,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,2,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.84,0.5,0.0],\"accuracy\":[0.825,0.7,0.95,0.625],\"jaccard_index\":[0.0,0.6363636363636364,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.55,\"precision\":0.6111111111111112,\"recall\":0.7586206896551724},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":4,\"extra_count\":11,\"total_count\":40,\"ds_count\":36,\"gt_count\":29,\"mean_iou\":0.55702524096093,\"accuracy\":0.625},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"conflict_count\":34,\"warning_count\":16,\"error_count\":18,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":4,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6}},\"1\":{\"conflicts\":[{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":130,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":128,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":124,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":125,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":127,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":129,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"}],\"annotations\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,6,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[1.0,0.0,1.0,0.0],\"jaccard_index\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"mean_iou\":0.0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":6,\"warning_count\":0,\"error_count\":6,\"conflicts_by_type\":{\"missing_annotation\":6}},\"2\":{\"conflicts\":[],\"annotations\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0,0.0],\"jaccard_index\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"mean_iou\":0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":0,\"warning_count\":0,\"error_count\":0,\"conflicts_by_type\":{}}}}" } }, @@ -11934,6 +11939,7 @@ "target_last_updated": "2023-11-24T15:23:30.269Z", "gt_last_updated": "2023-11-24T15:18:55.216Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.2727272727272727,\"frames\":[0,1,2],\"conflict_count\":40,\"warning_count\":16,\"error_count\":24,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":10,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6},\"annotations\":{\"valid_count\":22,\"missing_count\":10,\"extra_count\":11,\"total_count\":46,\"ds_count\":36,\"gt_count\":35,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,8,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.6774193548387096,0.5,0.0],\"accuracy\":[0.8478260869565217,0.6086956521739131,0.9565217391304348,0.5434782608695652],\"jaccard_index\":[0.0,0.5384615384615384,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.4782608695652174,\"precision\":0.6111111111111112,\"recall\":0.6285714285714286},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":10,\"extra_count\":11,\"total_count\":46,\"ds_count\":36,\"gt_count\":35,\"mean_iou\":0.18567508032031,\"accuracy\":0.5434782608695652},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"frame_count\":3,\"mean_conflict_count\":13.333333333333334},\"frame_results\":{\"0\":{\"conflicts\":[{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":91,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":118,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":88,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":102,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":68,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"},{\"obj_id\":98,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":141,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"},{\"obj_id\":132,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":107,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":121,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":122,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":131,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":70,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":76,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":93,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":74,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":95,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":73,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":96,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":64,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":66,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":94,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":142,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":89,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":123,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"},{\"obj_id\":97,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_direction\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"covered_annotation\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_attributes\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":82,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":103,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":81,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":104,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":83,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":105,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":87,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":106,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":80,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":111,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":77,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":114,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"}],\"annotations\":{\"valid_count\":22,\"missing_count\":4,\"extra_count\":11,\"total_count\":40,\"ds_count\":36,\"gt_count\":29,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,2,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.84,0.5,0.0],\"accuracy\":[0.825,0.7,0.95,0.625],\"jaccard_index\":[0.0,0.6363636363636364,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.55,\"precision\":0.6111111111111112,\"recall\":0.7586206896551724},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":4,\"extra_count\":11,\"total_count\":40,\"ds_count\":36,\"gt_count\":29,\"mean_iou\":0.55702524096093,\"accuracy\":0.625},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"conflict_count\":34,\"warning_count\":16,\"error_count\":18,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":4,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6}},\"1\":{\"conflicts\":[{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":130,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":128,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":124,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":125,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":127,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":129,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"}],\"annotations\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,6,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[1.0,0.0,1.0,0.0],\"jaccard_index\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"mean_iou\":0.0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":6,\"warning_count\":0,\"error_count\":6,\"conflicts_by_type\":{\"missing_annotation\":6}},\"2\":{\"conflicts\":[],\"annotations\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0,0.0],\"jaccard_index\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"mean_iou\":0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":0,\"warning_count\":0,\"error_count\":0,\"conflicts_by_type\":{}}}}" } }, @@ -11948,6 +11954,7 @@ "target_last_updated": "2024-03-21T20:50:05.947Z", "gt_last_updated": "2024-03-21T20:50:20.020Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.2727272727272727,\"frames\":[4,5,7],\"conflict_count\":3,\"warning_count\":1,\"error_count\":2,\"conflicts_by_type\":{\"low_overlap\":1,\"missing_annotation\":1,\"extra_annotation\":1},\"annotations\":{\"valid_count\":2,\"missing_count\":1,\"extra_count\":1,\"total_count\":4,\"ds_count\":3,\"gt_count\":3,\"confusion_matrix\":{\"labels\":[\"cat\",\"dog\",\"unmatched\"],\"rows\":[[2,0,1],[0,0,0],[1,0,0]],\"precision\":[0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.5,\"precision\":0.6666666666666666,\"recall\":0.6666666666666666},\"annotation_components\":{\"shape\":{\"valid_count\":2,\"missing_count\":1,\"extra_count\":1,\"total_count\":4,\"ds_count\":3,\"gt_count\":3,\"mean_iou\":null,\"accuracy\":0.5},\"label\":{\"valid_count\":2,\"invalid_count\":0,\"total_count\":2,\"accuracy\":1.0}},\"frame_count\":3,\"mean_conflict_count\":1.0},\"frame_results\":{\"5\":{\"conflicts\":[{\"frame_id\":5,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":156,\"job_id\":30,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":163,\"job_id\":32,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"}],\"annotations\":{\"valid_count\":1,\"missing_count\":0,\"extra_count\":0,\"total_count\":1,\"ds_count\":1,\"gt_count\":1,\"confusion_matrix\":{\"labels\":[\"cat\",\"dog\",\"unmatched\"],\"rows\":[[1,0,0],[0,0,0],[0,0,0]],\"precision\":[1.0,0.0,0.0],\"recall\":[1.0,0.0,0.0],\"accuracy\":[1.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":1.0,\"precision\":1.0,\"recall\":1.0},\"annotation_components\":{\"shape\":{\"valid_count\":1,\"missing_count\":0,\"extra_count\":0,\"total_count\":1,\"ds_count\":1,\"gt_count\":1,\"mean_iou\":0.6077449295377204,\"accuracy\":1.0},\"label\":{\"valid_count\":1,\"invalid_count\":0,\"total_count\":1,\"accuracy\":1.0}},\"conflict_count\":1,\"warning_count\":1,\"error_count\":0,\"conflicts_by_type\":{\"low_overlap\":1}},\"7\":{\"conflicts\":[],\"annotations\":{\"valid_count\":1,\"missing_count\":0,\"extra_count\":0,\"total_count\":1,\"ds_count\":1,\"gt_count\":1,\"confusion_matrix\":{\"labels\":[\"cat\",\"dog\",\"unmatched\"],\"rows\":[[1,0,0],[0,0,0],[0,0,0]],\"precision\":[1.0,0.0,0.0],\"recall\":[1.0,0.0,0.0],\"accuracy\":[1.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":1.0,\"precision\":1.0,\"recall\":1.0},\"annotation_components\":{\"shape\":{\"valid_count\":1,\"missing_count\":0,\"extra_count\":0,\"total_count\":1,\"ds_count\":1,\"gt_count\":1,\"mean_iou\":0.8588610910105674,\"accuracy\":1.0},\"label\":{\"valid_count\":1,\"invalid_count\":0,\"total_count\":1,\"accuracy\":1.0}},\"conflict_count\":0,\"warning_count\":0,\"error_count\":0,\"conflicts_by_type\":{}},\"4\":{\"conflicts\":[{\"frame_id\":4,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":162,\"job_id\":32,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":4,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":155,\"job_id\":29,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"}],\"annotations\":{\"valid_count\":0,\"missing_count\":1,\"extra_count\":1,\"total_count\":2,\"ds_count\":1,\"gt_count\":1,\"confusion_matrix\":{\"labels\":[\"cat\",\"dog\",\"unmatched\"],\"rows\":[[0,0,1],[0,0,0],[1,0,0]],\"precision\":[0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":1,\"extra_count\":1,\"total_count\":2,\"ds_count\":1,\"gt_count\":1,\"mean_iou\":0.1548852356623416,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":2,\"warning_count\":0,\"error_count\":2,\"conflicts_by_type\":{\"missing_annotation\":1,\"extra_annotation\":1}}}}" } }, @@ -11962,6 +11969,7 @@ "target_last_updated": "2024-03-21T20:50:27.594Z", "gt_last_updated": "2024-03-21T20:50:20.020Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.0,\"frames\":[],\"conflict_count\":0,\"warning_count\":0,\"error_count\":0,\"conflicts_by_type\":{},\"annotations\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"confusion_matrix\":{\"labels\":[\"cat\",\"dog\",\"unmatched\"],\"rows\":[[0,0,0],[0,0,0],[0,0,0]],\"precision\":[0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"mean_iou\":null,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"frame_count\":0,\"mean_conflict_count\":0.0},\"frame_results\":{}}" } }, @@ -11976,6 +11984,7 @@ "target_last_updated": "2024-03-21T20:50:33.610Z", "gt_last_updated": "2024-03-21T20:50:20.020Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.4,\"frames\":[5,7],\"conflict_count\":1,\"warning_count\":1,\"error_count\":0,\"conflicts_by_type\":{\"low_overlap\":1},\"annotations\":{\"valid_count\":2,\"missing_count\":0,\"extra_count\":0,\"total_count\":2,\"ds_count\":2,\"gt_count\":2,\"confusion_matrix\":{\"labels\":[\"cat\",\"dog\",\"unmatched\"],\"rows\":[[2,0,0],[0,0,0],[0,0,0]],\"precision\":[1.0,0.0,0.0],\"recall\":[1.0,0.0,0.0],\"accuracy\":[1.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":1.0,\"precision\":1.0,\"recall\":1.0},\"annotation_components\":{\"shape\":{\"valid_count\":2,\"missing_count\":0,\"extra_count\":0,\"total_count\":2,\"ds_count\":2,\"gt_count\":2,\"mean_iou\":0.7333030102741439,\"accuracy\":1.0},\"label\":{\"valid_count\":2,\"invalid_count\":0,\"total_count\":2,\"accuracy\":1.0}},\"frame_count\":2,\"mean_conflict_count\":0.5},\"frame_results\":{\"5\":{\"conflicts\":[{\"frame_id\":5,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":156,\"job_id\":30,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":163,\"job_id\":32,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"}],\"annotations\":{\"valid_count\":1,\"missing_count\":0,\"extra_count\":0,\"total_count\":1,\"ds_count\":1,\"gt_count\":1,\"confusion_matrix\":{\"labels\":[\"cat\",\"dog\",\"unmatched\"],\"rows\":[[1,0,0],[0,0,0],[0,0,0]],\"precision\":[1.0,0.0,0.0],\"recall\":[1.0,0.0,0.0],\"accuracy\":[1.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":1.0,\"precision\":1.0,\"recall\":1.0},\"annotation_components\":{\"shape\":{\"valid_count\":1,\"missing_count\":0,\"extra_count\":0,\"total_count\":1,\"ds_count\":1,\"gt_count\":1,\"mean_iou\":0.6077449295377204,\"accuracy\":1.0},\"label\":{\"valid_count\":1,\"invalid_count\":0,\"total_count\":1,\"accuracy\":1.0}},\"conflict_count\":1,\"warning_count\":1,\"error_count\":0,\"conflicts_by_type\":{\"low_overlap\":1}},\"7\":{\"conflicts\":[],\"annotations\":{\"valid_count\":1,\"missing_count\":0,\"extra_count\":0,\"total_count\":1,\"ds_count\":1,\"gt_count\":1,\"confusion_matrix\":{\"labels\":[\"cat\",\"dog\",\"unmatched\"],\"rows\":[[1,0,0],[0,0,0],[0,0,0]],\"precision\":[1.0,0.0,0.0],\"recall\":[1.0,0.0,0.0],\"accuracy\":[1.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":1.0,\"precision\":1.0,\"recall\":1.0},\"annotation_components\":{\"shape\":{\"valid_count\":1,\"missing_count\":0,\"extra_count\":0,\"total_count\":1,\"ds_count\":1,\"gt_count\":1,\"mean_iou\":0.8588610910105674,\"accuracy\":1.0},\"label\":{\"valid_count\":1,\"invalid_count\":0,\"total_count\":1,\"accuracy\":1.0}},\"conflict_count\":0,\"warning_count\":0,\"error_count\":0,\"conflicts_by_type\":{}}}}" } }, @@ -11990,6 +11999,7 @@ "target_last_updated": "2024-03-21T20:50:39.585Z", "gt_last_updated": "2024-03-21T20:50:20.020Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.2,\"frames\":[4],\"conflict_count\":2,\"warning_count\":0,\"error_count\":2,\"conflicts_by_type\":{\"missing_annotation\":1,\"extra_annotation\":1},\"annotations\":{\"valid_count\":0,\"missing_count\":1,\"extra_count\":1,\"total_count\":2,\"ds_count\":1,\"gt_count\":1,\"confusion_matrix\":{\"labels\":[\"cat\",\"dog\",\"unmatched\"],\"rows\":[[0,0,1],[0,0,0],[1,0,0]],\"precision\":[0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":1,\"extra_count\":1,\"total_count\":2,\"ds_count\":1,\"gt_count\":1,\"mean_iou\":0.1548852356623416,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"frame_count\":1,\"mean_conflict_count\":2.0},\"frame_results\":{\"4\":{\"conflicts\":[{\"frame_id\":4,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":162,\"job_id\":32,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":4,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":155,\"job_id\":29,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"}],\"annotations\":{\"valid_count\":0,\"missing_count\":1,\"extra_count\":1,\"total_count\":2,\"ds_count\":1,\"gt_count\":1,\"confusion_matrix\":{\"labels\":[\"cat\",\"dog\",\"unmatched\"],\"rows\":[[0,0,1],[0,0,0],[1,0,0]],\"precision\":[0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":1,\"extra_count\":1,\"total_count\":2,\"ds_count\":1,\"gt_count\":1,\"mean_iou\":0.1548852356623416,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":2,\"warning_count\":0,\"error_count\":2,\"conflicts_by_type\":{\"missing_annotation\":1,\"extra_annotation\":1}}}}" } }, @@ -12004,6 +12014,7 @@ "target_last_updated": "2023-11-24T15:23:30.045Z", "gt_last_updated": "2023-11-24T15:18:55.216Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\",\"label\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.2727272727272727,\"frames\":[0,1,2],\"conflict_count\":42,\"warning_count\":16,\"error_count\":26,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":12,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6},\"annotations\":{\"valid_count\":22,\"missing_count\":12,\"extra_count\":11,\"total_count\":48,\"ds_count\":36,\"gt_count\":37,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,10,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.6363636363636364,0.5,0.0],\"accuracy\":[0.8541666666666666,0.5833333333333334,0.9583333333333334,0.5208333333333334],\"jaccard_index\":[0.0,0.5121951219512195,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.4583333333333333,\"precision\":0.6111111111111112,\"recall\":0.5945945945945946},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":10,\"extra_count\":11,\"total_count\":46,\"ds_count\":36,\"gt_count\":35,\"mean_iou\":0.18567508032031,\"accuracy\":0.5434782608695652},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"frame_count\":3,\"mean_conflict_count\":14.0},\"frame_results\":{\"0\":{\"conflicts\":[{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":91,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":118,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":88,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":102,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":68,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"},{\"obj_id\":98,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":141,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"},{\"obj_id\":132,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":107,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":121,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":122,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":131,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":7,\"job_id\":28,\"type\":\"tag\",\"shape_type\":null}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":70,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":76,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":93,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":74,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":95,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":73,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":96,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":64,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":66,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":94,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":142,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":89,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":123,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"},{\"obj_id\":97,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_direction\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"covered_annotation\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_attributes\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":82,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":103,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":81,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":104,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":83,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":105,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":87,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":106,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":80,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":111,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":77,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":114,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"}],\"annotations\":{\"valid_count\":22,\"missing_count\":5,\"extra_count\":11,\"total_count\":41,\"ds_count\":36,\"gt_count\":30,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,3,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.8076923076923077,0.5,0.0],\"accuracy\":[0.8292682926829268,0.6829268292682927,0.9512195121951219,0.6097560975609756],\"jaccard_index\":[0.0,0.6176470588235294,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.5365853658536586,\"precision\":0.6111111111111112,\"recall\":0.7333333333333333},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":4,\"extra_count\":11,\"total_count\":40,\"ds_count\":36,\"gt_count\":29,\"mean_iou\":0.55702524096093,\"accuracy\":0.625},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"conflict_count\":35,\"warning_count\":16,\"error_count\":19,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":5,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6}},\"1\":{\"conflicts\":[{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":130,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":128,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":124,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":125,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":127,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":129,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":8,\"job_id\":28,\"type\":\"tag\",\"shape_type\":null}],\"severity\":\"error\"}],\"annotations\":{\"valid_count\":0,\"missing_count\":7,\"extra_count\":0,\"total_count\":7,\"ds_count\":0,\"gt_count\":7,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,7,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[1.0,0.0,1.0,0.0],\"jaccard_index\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"mean_iou\":0.0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":7,\"warning_count\":0,\"error_count\":7,\"conflicts_by_type\":{\"missing_annotation\":7}},\"2\":{\"conflicts\":[],\"annotations\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0,0.0],\"jaccard_index\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"mean_iou\":0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":0,\"warning_count\":0,\"error_count\":0,\"conflicts_by_type\":{}}}}" } }, @@ -12018,6 +12029,7 @@ "target_last_updated": "2023-11-24T15:23:30.269Z", "gt_last_updated": "2023-11-24T15:18:55.216Z", "assignee": null, + "assignee_last_updated": null, "data": "{\"parameters\":{\"included_annotation_types\":[\"bbox\",\"points\",\"mask\",\"polygon\",\"polyline\",\"skeleton\",\"label\"],\"compare_attributes\":true,\"ignored_attributes\":[],\"iou_threshold\":0.4,\"low_overlap_threshold\":0.8,\"oks_sigma\":0.09,\"line_thickness\":0.01,\"compare_line_orientation\":true,\"line_orientation_threshold\":0.1,\"compare_groups\":true,\"group_match_threshold\":0.5,\"check_covered_annotations\":true,\"object_visibility_threshold\":0.05,\"panoptic_comparison\":true},\"comparison_summary\":{\"frame_share\":0.2727272727272727,\"frames\":[0,1,2],\"conflict_count\":42,\"warning_count\":16,\"error_count\":26,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":12,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6},\"annotations\":{\"valid_count\":22,\"missing_count\":12,\"extra_count\":11,\"total_count\":48,\"ds_count\":36,\"gt_count\":37,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,10,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.6363636363636364,0.5,0.0],\"accuracy\":[0.8541666666666666,0.5833333333333334,0.9583333333333334,0.5208333333333334],\"jaccard_index\":[0.0,0.5121951219512195,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.4583333333333333,\"precision\":0.6111111111111112,\"recall\":0.5945945945945946},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":10,\"extra_count\":11,\"total_count\":46,\"ds_count\":36,\"gt_count\":35,\"mean_iou\":0.18567508032031,\"accuracy\":0.5434782608695652},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"frame_count\":3,\"mean_conflict_count\":14.0},\"frame_results\":{\"0\":{\"conflicts\":[{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":91,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":118,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":88,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":102,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":68,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"},{\"obj_id\":98,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":141,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"},{\"obj_id\":132,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"low_overlap\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":107,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":121,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":122,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":131,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":7,\"job_id\":28,\"type\":\"tag\",\"shape_type\":null}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":70,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":76,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":93,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":74,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":95,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":73,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":96,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":64,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":66,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":94,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"extra_annotation\",\"annotation_ids\":[{\"obj_id\":142,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"skeleton\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":92,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":119,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":89,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"points\"},{\"obj_id\":123,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_label\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"},{\"obj_id\":97,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":0,\"type\":\"mismatching_direction\",\"annotation_ids\":[{\"obj_id\":67,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"polyline\"},{\"obj_id\":99,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"covered_annotation\",\"annotation_ids\":[{\"obj_id\":69,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_attributes\",\"annotation_ids\":[{\"obj_id\":65,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":101,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":82,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":103,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":81,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":104,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":83,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":105,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":87,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":106,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":80,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":111,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"},{\"frame_id\":0,\"type\":\"mismatching_groups\",\"annotation_ids\":[{\"obj_id\":77,\"job_id\":27,\"type\":\"shape\",\"shape_type\":\"rectangle\"},{\"obj_id\":114,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"warning\"}],\"annotations\":{\"valid_count\":22,\"missing_count\":5,\"extra_count\":11,\"total_count\":41,\"ds_count\":36,\"gt_count\":30,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,2,0,3],[1,21,0,7],[0,0,1,1],[1,3,1,0]],\"precision\":[0.0,0.7241379310344828,0.5,0.0],\"recall\":[0.0,0.8076923076923077,0.5,0.0],\"accuracy\":[0.8292682926829268,0.6829268292682927,0.9512195121951219,0.6097560975609756],\"jaccard_index\":[0.0,0.6176470588235294,0.3333333333333333,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.5365853658536586,\"precision\":0.6111111111111112,\"recall\":0.7333333333333333},\"annotation_components\":{\"shape\":{\"valid_count\":25,\"missing_count\":4,\"extra_count\":11,\"total_count\":40,\"ds_count\":36,\"gt_count\":29,\"mean_iou\":0.55702524096093,\"accuracy\":0.625},\"label\":{\"valid_count\":22,\"invalid_count\":3,\"total_count\":25,\"accuracy\":0.88}},\"conflict_count\":35,\"warning_count\":16,\"error_count\":19,\"conflicts_by_type\":{\"low_overlap\":7,\"missing_annotation\":5,\"extra_annotation\":11,\"mismatching_label\":3,\"mismatching_direction\":1,\"covered_annotation\":1,\"mismatching_attributes\":1,\"mismatching_groups\":6}},\"1\":{\"conflicts\":[{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":130,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"rectangle\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":128,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"points\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":124,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polygon\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":125,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"ellipse\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":127,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"mask\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":129,\"job_id\":28,\"type\":\"shape\",\"shape_type\":\"polyline\"}],\"severity\":\"error\"},{\"frame_id\":1,\"type\":\"missing_annotation\",\"annotation_ids\":[{\"obj_id\":8,\"job_id\":28,\"type\":\"tag\",\"shape_type\":null}],\"severity\":\"error\"}],\"annotations\":{\"valid_count\":0,\"missing_count\":7,\"extra_count\":0,\"total_count\":7,\"ds_count\":0,\"gt_count\":7,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,7,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[1.0,0.0,1.0,0.0],\"jaccard_index\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":6,\"extra_count\":0,\"total_count\":6,\"ds_count\":0,\"gt_count\":6,\"mean_iou\":0.0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":7,\"warning_count\":0,\"error_count\":7,\"conflicts_by_type\":{\"missing_annotation\":7}},\"2\":{\"conflicts\":[],\"annotations\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"confusion_matrix\":{\"labels\":[\"dog\",\"cat\",\"skele\",\"unmatched\"],\"rows\":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]],\"precision\":[0.0,0.0,0.0,0.0],\"recall\":[0.0,0.0,0.0,0.0],\"accuracy\":[0.0,0.0,0.0,0.0],\"jaccard_index\":[0.0,0.0,0.0,0.0],\"axes\":{\"cols\":\"gt\",\"rows\":\"ds\"}},\"accuracy\":0.0,\"precision\":0.0,\"recall\":0.0},\"annotation_components\":{\"shape\":{\"valid_count\":0,\"missing_count\":0,\"extra_count\":0,\"total_count\":0,\"ds_count\":0,\"gt_count\":0,\"mean_iou\":0,\"accuracy\":0.0},\"label\":{\"valid_count\":0,\"invalid_count\":0,\"total_count\":0,\"accuracy\":0.0}},\"conflict_count\":0,\"warning_count\":0,\"error_count\":0,\"conflicts_by_type\":{}}}}" } }, @@ -16231,7 +16243,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16250,7 +16265,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16269,7 +16287,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16288,7 +16309,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16307,7 +16331,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16326,7 +16353,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16345,7 +16375,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16364,7 +16397,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16383,7 +16419,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16402,7 +16441,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16421,7 +16463,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16440,7 +16485,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16459,7 +16507,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16478,7 +16529,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16497,7 +16551,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16516,7 +16573,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16535,7 +16595,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16554,7 +16617,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16573,7 +16639,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { @@ -16592,7 +16661,10 @@ "check_covered_annotations": true, "object_visibility_threshold": 0.05, "panoptic_comparison": true, - "compare_attributes": true + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 } }, { diff --git a/tests/python/shared/assets/quality_reports.json b/tests/python/shared/assets/quality_reports.json index b1fa8173dfe6..64ed156e6da5 100644 --- a/tests/python/shared/assets/quality_reports.json +++ b/tests/python/shared/assets/quality_reports.json @@ -11,6 +11,7 @@ "job_id": null, "parent_id": null, "summary": { + "accuracy": 0.4883720930232558, "conflict_count": 37, "conflicts_by_type": { "covered_annotation": 1, @@ -27,6 +28,8 @@ "frame_count": 3, "frame_share": 0.2727272727272727, "gt_count": 33, + "precision": 0.6176470588235294, + "recall": 0.6363636363636364, "total_count": 43, "valid_count": 21, "warning_count": 15 @@ -43,6 +46,7 @@ "job_id": 27, "parent_id": 1, "summary": { + "accuracy": 0.4883720930232558, "conflict_count": 37, "conflicts_by_type": { "covered_annotation": 1, @@ -59,6 +63,8 @@ "frame_count": 3, "frame_share": 0.2727272727272727, "gt_count": 33, + "precision": 0.6176470588235294, + "recall": 0.6363636363636364, "total_count": 43, "valid_count": 21, "warning_count": 15 @@ -75,6 +81,7 @@ "job_id": null, "parent_id": null, "summary": { + "accuracy": 0.4782608695652174, "conflict_count": 40, "conflicts_by_type": { "covered_annotation": 1, @@ -91,6 +98,8 @@ "frame_count": 3, "frame_share": 0.2727272727272727, "gt_count": 35, + "precision": 0.6111111111111112, + "recall": 0.6285714285714286, "total_count": 46, "valid_count": 22, "warning_count": 16 @@ -107,6 +116,7 @@ "job_id": 27, "parent_id": 3, "summary": { + "accuracy": 0.4782608695652174, "conflict_count": 40, "conflicts_by_type": { "covered_annotation": 1, @@ -123,6 +133,8 @@ "frame_count": 3, "frame_share": 0.2727272727272727, "gt_count": 35, + "precision": 0.6111111111111112, + "recall": 0.6285714285714286, "total_count": 46, "valid_count": 22, "warning_count": 16 @@ -139,6 +151,7 @@ "job_id": null, "parent_id": null, "summary": { + "accuracy": 0.4782608695652174, "conflict_count": 40, "conflicts_by_type": { "covered_annotation": 1, @@ -155,6 +168,8 @@ "frame_count": 3, "frame_share": 0.2727272727272727, "gt_count": 35, + "precision": 0.6111111111111112, + "recall": 0.6285714285714286, "total_count": 46, "valid_count": 22, "warning_count": 16 @@ -171,6 +186,7 @@ "job_id": 27, "parent_id": 5, "summary": { + "accuracy": 0.4782608695652174, "conflict_count": 40, "conflicts_by_type": { "covered_annotation": 1, @@ -187,6 +203,8 @@ "frame_count": 3, "frame_share": 0.2727272727272727, "gt_count": 35, + "precision": 0.6111111111111112, + "recall": 0.6285714285714286, "total_count": 46, "valid_count": 22, "warning_count": 16 @@ -203,6 +221,7 @@ "job_id": null, "parent_id": null, "summary": { + "accuracy": 0.5, "conflict_count": 3, "conflicts_by_type": { "extra_annotation": 1, @@ -214,6 +233,8 @@ "frame_count": 3, "frame_share": 0.2727272727272727, "gt_count": 3, + "precision": 0.6666666666666666, + "recall": 0.6666666666666666, "total_count": 4, "valid_count": 2, "warning_count": 1 @@ -230,6 +251,7 @@ "job_id": 31, "parent_id": 7, "summary": { + "accuracy": 0.0, "conflict_count": 0, "conflicts_by_type": {}, "ds_count": 0, @@ -237,6 +259,8 @@ "frame_count": 0, "frame_share": 0.0, "gt_count": 0, + "precision": 0.0, + "recall": 0.0, "total_count": 0, "valid_count": 0, "warning_count": 0 @@ -253,6 +277,7 @@ "job_id": 30, "parent_id": 7, "summary": { + "accuracy": 1.0, "conflict_count": 1, "conflicts_by_type": { "low_overlap": 1 @@ -262,6 +287,8 @@ "frame_count": 2, "frame_share": 0.4, "gt_count": 2, + "precision": 1.0, + "recall": 1.0, "total_count": 2, "valid_count": 2, "warning_count": 1 @@ -278,6 +305,7 @@ "job_id": 29, "parent_id": 7, "summary": { + "accuracy": 0.0, "conflict_count": 2, "conflicts_by_type": { "extra_annotation": 1, @@ -288,6 +316,8 @@ "frame_count": 1, "frame_share": 0.2, "gt_count": 1, + "precision": 0.0, + "recall": 0.0, "total_count": 2, "valid_count": 0, "warning_count": 0 @@ -304,6 +334,7 @@ "job_id": null, "parent_id": null, "summary": { + "accuracy": 0.4583333333333333, "conflict_count": 42, "conflicts_by_type": { "covered_annotation": 1, @@ -320,6 +351,8 @@ "frame_count": 3, "frame_share": 0.2727272727272727, "gt_count": 37, + "precision": 0.6111111111111112, + "recall": 0.5945945945945946, "total_count": 48, "valid_count": 22, "warning_count": 16 @@ -336,6 +369,7 @@ "job_id": 27, "parent_id": 11, "summary": { + "accuracy": 0.4583333333333333, "conflict_count": 42, "conflicts_by_type": { "covered_annotation": 1, @@ -352,6 +386,8 @@ "frame_count": 3, "frame_share": 0.2727272727272727, "gt_count": 37, + "precision": 0.6111111111111112, + "recall": 0.5945945945945946, "total_count": 48, "valid_count": 22, "warning_count": 16 diff --git a/tests/python/shared/assets/quality_settings.json b/tests/python/shared/assets/quality_settings.json index e23c786e7379..e38feba4b2e4 100644 --- a/tests/python/shared/assets/quality_settings.json +++ b/tests/python/shared/assets/quality_settings.json @@ -14,9 +14,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 2 }, { @@ -30,9 +33,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 5 }, { @@ -46,9 +52,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 6 }, { @@ -62,9 +71,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 7 }, { @@ -78,9 +90,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 8 }, { @@ -94,9 +109,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 9 }, { @@ -110,9 +128,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 11 }, { @@ -126,9 +147,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 12 }, { @@ -142,9 +166,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 13 }, { @@ -158,9 +185,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 14 }, { @@ -174,9 +204,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 15 }, { @@ -190,9 +223,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 17 }, { @@ -206,9 +242,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 18 }, { @@ -222,9 +261,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 19 }, { @@ -238,9 +280,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 20 }, { @@ -254,9 +299,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 21 }, { @@ -270,9 +318,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 22 }, { @@ -286,9 +337,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 23 }, { @@ -302,9 +356,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 24 }, { @@ -318,9 +375,12 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, "task_id": 25 } ] From bf09a38ff7273a11f7531e4ece5335dbdff7f6ea Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 19 Aug 2024 23:20:49 +0300 Subject: [PATCH 090/227] Update server schema --- cvat/schema.yml | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/cvat/schema.yml b/cvat/schema.yml index d4f01b9642ac..eb32393fa257 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -9320,6 +9320,28 @@ components: PatchedQualitySettingsRequest: type: object properties: + target_metric: + allOf: + - $ref: '#/components/schemas/TargetMetricEnum' + description: |- + The primary metric used for quality estimation + + * `accuracy` - ACCURACY + * `precision` - PRECISION + * `recall` - RECALL + target_metric_threshold: + type: number + format: double + description: | + Defines the minimal quality requirements in terms of the selected target metric. + max_validations_per_job: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: | + The maximum number of job validation attempts for the job assignee. + The job can be automatically accepted if the job quality is above the required + threshold, defined by the target threshold parameter. iou_threshold: type: number format: double @@ -9719,7 +9741,17 @@ components: type: integer total_count: type: integer + accuracy: + type: number + format: double + precision: + type: number + format: double + recall: + type: number + format: double required: + - accuracy - conflict_count - conflicts_by_type - ds_count @@ -9727,6 +9759,8 @@ components: - frame_count - frame_share - gt_count + - precision + - recall - total_count - valid_count - warning_count @@ -9747,6 +9781,28 @@ components: task_id: type: integer readOnly: true + target_metric: + allOf: + - $ref: '#/components/schemas/TargetMetricEnum' + description: |- + The primary metric used for quality estimation + + * `accuracy` - ACCURACY + * `precision` - PRECISION + * `recall` - RECALL + target_metric_threshold: + type: number + format: double + description: | + Defines the minimal quality requirements in terms of the selected target metric. + max_validations_per_job: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: | + The maximum number of job validation attempts for the job assignee. + The job can be automatically accepted if the job quality is above the required + threshold, defined by the target threshold parameter. iou_threshold: type: number format: double @@ -10325,6 +10381,16 @@ components: type: boolean required: - name + TargetMetricEnum: + enum: + - accuracy + - precision + - recall + type: string + description: |- + * `accuracy` - ACCURACY + * `precision` - PRECISION + * `recall` - RECALL TaskAnnotationsUpdateRequest: oneOf: - $ref: '#/components/schemas/LabeledDataRequest' From abb214fd5daf1e69ff1546d26110848775e2a815 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 20 Aug 2024 10:51:59 +0300 Subject: [PATCH 091/227] Fix formatting --- .../0003_qualityreport_assignee_last_updated_and_more.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py b/cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py index 529f52f0d375..aa27c08c00e1 100644 --- a/cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py +++ b/cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py @@ -1,8 +1,9 @@ # Generated by Django 4.2.14 on 2024-08-19 17:23 -import cvat.apps.quality_control.models from django.db import migrations, models +import cvat.apps.quality_control.models + class Migration(migrations.Migration): From 441d0e75ca2a640d8dedba440a5accdfc5c4680e Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 21 Aug 2024 16:32:48 +0300 Subject: [PATCH 092/227] Allow calling flushall in redis in helm tests --- helm-chart/test.values.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/helm-chart/test.values.yaml b/helm-chart/test.values.yaml index 5a5fa8fe6bab..24802a0cc546 100644 --- a/helm-chart/test.values.yaml +++ b/helm-chart/test.values.yaml @@ -27,6 +27,12 @@ cvat: frontend: imagePullPolicy: Never +redis: + master: + # The "flushall" command, which we use in tests, is disabled in helm by default + # https://github.com/helm/charts/tree/master/stable/redis#parameters + disableCommands: [] + keydb: resources: requests: From 1d29c2f5fa6677197aae8dfe46a23f79136d2a59 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 21 Aug 2024 16:58:55 +0300 Subject: [PATCH 093/227] Refactor field definition --- .../0003_qualityreport_assignee_last_updated_and_more.py | 4 ++-- cvat/apps/quality_control/models.py | 2 +- cvat/apps/quality_control/serializers.py | 3 --- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py b/cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py index aa27c08c00e1..0b9baee44544 100644 --- a/cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py +++ b/cvat/apps/quality_control/migrations/0003_qualityreport_assignee_last_updated_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.14 on 2024-08-19 17:23 +# Generated by Django 4.2.15 on 2024-08-21 13:56 from django.db import migrations, models @@ -20,7 +20,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="qualitysettings", name="max_validations_per_job", - field=models.IntegerField(default=0), + field=models.PositiveIntegerField(default=0), ), migrations.AddField( model_name="qualitysettings", diff --git a/cvat/apps/quality_control/models.py b/cvat/apps/quality_control/models.py index 6dc2f9384d0d..37f0f1f9612d 100644 --- a/cvat/apps/quality_control/models.py +++ b/cvat/apps/quality_control/models.py @@ -226,7 +226,7 @@ class QualitySettings(models.Model): target_metric_threshold = models.FloatField(default=0.7) - max_validations_per_job = models.IntegerField(default=0) + max_validations_per_job = models.PositiveIntegerField(default=0) def __init__(self, *args: Any, **kwargs: Any) -> None: defaults = deepcopy(self.get_defaults()) diff --git a/cvat/apps/quality_control/serializers.py b/cvat/apps/quality_control/serializers.py index 0688a4f9e6a0..0a669962c8c9 100644 --- a/cvat/apps/quality_control/serializers.py +++ b/cvat/apps/quality_control/serializers.py @@ -158,7 +158,4 @@ def validate(self, attrs): if not 0 <= v <= 1: raise serializers.ValidationError(f"{k} must be in the range [0; 1]") - if (max_validations := attrs.get("max_validations_per_job")) and max_validations < 0: - raise serializers.ValidationError("max_validations_per_job cannot be less than 0") - return super().validate(attrs) From d81e1dacd041d4ec877a10b91042a940ba137df6 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 21 Aug 2024 17:00:34 +0300 Subject: [PATCH 094/227] Update server schema --- cvat/schema.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat/schema.yml b/cvat/schema.yml index eb32393fa257..d0dc8555789a 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -9337,7 +9337,7 @@ components: max_validations_per_job: type: integer maximum: 2147483647 - minimum: -2147483648 + minimum: 0 description: | The maximum number of job validation attempts for the job assignee. The job can be automatically accepted if the job quality is above the required @@ -9798,7 +9798,7 @@ components: max_validations_per_job: type: integer maximum: 2147483647 - minimum: -2147483648 + minimum: 0 description: | The maximum number of job validation attempts for the job assignee. The job can be automatically accepted if the job quality is above the required From 0963f9490451f7949ff27a63ecc7a006e705f23f Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 21 Aug 2024 17:19:50 +0300 Subject: [PATCH 095/227] Update comment --- helm-chart/test.values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm-chart/test.values.yaml b/helm-chart/test.values.yaml index 24802a0cc546..73edaa815d70 100644 --- a/helm-chart/test.values.yaml +++ b/helm-chart/test.values.yaml @@ -30,7 +30,7 @@ cvat: redis: master: # The "flushall" command, which we use in tests, is disabled in helm by default - # https://github.com/helm/charts/tree/master/stable/redis#parameters + # https://artifacthub.io/packages/helm/bitnami/redis#redis-master-configuration-parameters disableCommands: [] keydb: From 0d78e6380b688707f23c40cc14b0d68a0ce46acc Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 21 Aug 2024 17:20:08 +0300 Subject: [PATCH 096/227] Update redis cleanup command --- tests/python/shared/fixtures/init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/shared/fixtures/init.py b/tests/python/shared/fixtures/init.py index 45fa543eb922..cf5aeabbbf37 100644 --- a/tests/python/shared/fixtures/init.py +++ b/tests/python/shared/fixtures/init.py @@ -244,7 +244,7 @@ def docker_restore_redis_inmem(): def kube_restore_redis_inmem(): - kube_exec_redis_inmem(["redis-cli", "-e", "flushall"]) + kube_exec_redis_inmem(["sh", "-c", 'redis-cli -e -a "${REDIS_PASSWORD}" flushall']) def docker_restore_redis_ondisk(): From 3caab1b44c236df636781707da9e0f4fa677c328 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 23 Aug 2024 22:45:43 +0300 Subject: [PATCH 097/227] Implement task creation with honeypot --- cvat/apps/engine/cache.py | 79 +++--- cvat/apps/engine/frame_provider.py | 12 +- cvat/apps/engine/media_extractors.py | 2 +- .../migrations/0080_image_is_placeholder.py | 18 -- .../migrations/0081_image_real_frame_id.py | 18 -- ...aceholder_image_real_frame_id_and_more.py} | 61 ++--- cvat/apps/engine/models.py | 17 +- cvat/apps/engine/serializers.py | 62 +++-- cvat/apps/engine/task.py | 257 +++++++++--------- cvat/apps/engine/views.py | 4 +- cvat/schema.yml | 20 +- 11 files changed, 271 insertions(+), 279 deletions(-) delete mode 100644 cvat/apps/engine/migrations/0080_image_is_placeholder.py delete mode 100644 cvat/apps/engine/migrations/0081_image_real_frame_id.py rename cvat/apps/engine/migrations/{0079_validationparams_validationimage_and_more.py => 0084_image_is_placeholder_image_real_frame_id_and_more.py} (54%) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 43e03ec288cd..4b0a7b720bda 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -350,7 +350,7 @@ def prepare_range_segment_chunk( db_data = db_task.data chunk_size = db_data.chunk_size - chunk_frame_ids = db_segment.frame_set[ + chunk_frame_ids = list(db_segment.frame_set)[ chunk_size * chunk_number : chunk_size * (chunk_number + 1) ] @@ -363,15 +363,14 @@ def prepare_masked_range_segment_chunk( db_task = db_segment.task db_data = db_task.data - from cvat.apps.engine.frame_provider import TaskFrameProvider - - frame_provider = TaskFrameProvider(db_task) - - frame_set = db_segment.frame_set + chunk_size = db_data.chunk_size + chunk_frame_ids = list(db_segment.frame_set)[ + chunk_size * chunk_number : chunk_size * (chunk_number + 1) + ] frame_step = db_data.get_frame_step() - chunk_frames = [] writer = ZipCompressedChunkWriter(db_data.image_quality, dimension=db_task.dimension) + dummy_frame = io.BytesIO() PIL.Image.new("RGB", (1, 1)).save(dummy_frame, writer.IMAGE_EXT) @@ -380,46 +379,46 @@ def prepare_masked_range_segment_chunk( else: frame_size = None - for frame_idx in range(db_data.chunk_size): - frame_idx = ( - db_data.start_frame + chunk_number * db_data.chunk_size + frame_idx * frame_step - ) - if db_data.stop_frame < frame_idx: - break - - frame_bytes = None - - if frame_idx in frame_set: - frame_bytes = frame_provider.get_frame(frame_idx, quality=quality).data - - if frame_size is not None: - # Decoded video frames can have different size, restore the original one + def get_frames(): + with closing( + self._read_raw_frames(db_task, frame_ids=chunk_frame_ids) + ) as read_frame_iter: + for frame_idx in range(db_data.chunk_size): + frame_idx = ( + db_data.start_frame + + chunk_number * db_data.chunk_size + frame_idx * frame_step + ) + if db_data.stop_frame < frame_idx: + break - frame = PIL.Image.open(frame_bytes) - if frame.size != frame_size: - frame = frame.resize(frame_size) + if frame_idx in chunk_frame_ids: + frame = next(read_frame_iter)[0] - frame_bytes = io.BytesIO() - frame.save(frame_bytes, writer.IMAGE_EXT) - frame_bytes.seek(0) + if hasattr(db_data, "video"): + # Decoded video frames can have different size, restore the original one - else: - # Populate skipped frames with placeholder data, - # this is required for video chunk decoding implementation in UI - frame_bytes = io.BytesIO(dummy_frame.getvalue()) + frame = frame.to_image() + if frame.size != frame_size: + frame = frame.resize(frame_size) + else: + # Populate skipped frames with placeholder data, + # this is required for video chunk decoding implementation in UI + # TODO: try to fix decoding in UI + frame = io.BytesIO(dummy_frame.getvalue()) - if frame_bytes is not None: - chunk_frames.append((frame_bytes, None, None)) + yield (frame, None, None) buff = io.BytesIO() - writer.save_as_chunk( - chunk_frames, - buff, - compress_frames=False, - zip_compress_level=1, # there are likely to be many skips in SPECIFIC_FRAMES segments - ) - buff.seek(0) + with closing(get_frames()) as frame_iter: + writer.save_as_chunk( + frame_iter, + buff, + zip_compress_level=1, + # there are likely to be many skips with repeated placeholder frames + # in SPECIFIC_FRAMES segments, it makes sense to compress the archive + ) + buff.seek(0) return buff, get_chunk_mime_type_for_writer(writer) def _prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 11ea5539e29d..f5d5a48ed521 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -277,10 +277,12 @@ def get_chunk( [ s for s in self._db_task.segment_set.all() - if s.type == models.SegmentType.RANGE if not task_chunk_frame_set.isdisjoint(s.frame_set) ], - key=lambda s: s.start_frame, + key=lambda s: ( + s.type != models.SegmentType.RANGE, # prioritize RANGE segments, + s.start_frame, + ), ) assert matching_segments @@ -378,8 +380,10 @@ def _get_segment(self, validated_frame_number: int) -> models.Segment: return next( s - for s in self._db_task.segment_set.all() - if s.type == models.SegmentType.RANGE + for s in sorted( + self._db_task.segment_set.all(), + key=lambda s: s.type != models.SegmentType.RANGE # prioritize RANGE segments + ) if abs_frame_number in s.frame_set ) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 1d5c6fde76e5..0830199eed64 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -87,7 +87,7 @@ def sort(images, sorting_method=SortingMethod.LEXICOGRAPHICAL, func=None): elif sorting_method == SortingMethod.PREDEFINED: return images elif sorting_method == SortingMethod.RANDOM: - shuffle(images) + shuffle(images) # TODO: support seed to create reproducible results return images else: raise NotImplementedError() diff --git a/cvat/apps/engine/migrations/0080_image_is_placeholder.py b/cvat/apps/engine/migrations/0080_image_is_placeholder.py deleted file mode 100644 index fb6c3389c4ba..000000000000 --- a/cvat/apps/engine/migrations/0080_image_is_placeholder.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.13 on 2024-07-08 16:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0079_validationparams_validationimage_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="image", - name="is_placeholder", - field=models.BooleanField(default=False), - ), - ] diff --git a/cvat/apps/engine/migrations/0081_image_real_frame_id.py b/cvat/apps/engine/migrations/0081_image_real_frame_id.py deleted file mode 100644 index f0e660713c18..000000000000 --- a/cvat/apps/engine/migrations/0081_image_real_frame_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.13 on 2024-07-10 10:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0080_image_is_placeholder"), - ] - - operations = [ - migrations.AddField( - model_name="image", - name="real_frame_id", - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/cvat/apps/engine/migrations/0079_validationparams_validationimage_and_more.py b/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py similarity index 54% rename from cvat/apps/engine/migrations/0079_validationparams_validationimage_and_more.py rename to cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py index 4320a74f98e0..e27d399185e0 100644 --- a/cvat/apps/engine/migrations/0079_validationparams_validationimage_and_more.py +++ b/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.13 on 2024-07-08 13:34 +# Generated by Django 4.2.15 on 2024-08-23 16:13 from django.db import migrations, models import django.db.models.deletion @@ -7,35 +7,37 @@ class Migration(migrations.Migration): dependencies = [ - ("engine", "0078_alter_cloudstorage_credentials"), + ("engine", "0083_move_to_segment_chunks"), ] operations = [ + migrations.AddField( + model_name="image", + name="is_placeholder", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="image", + name="real_frame_id", + field=models.PositiveIntegerField(default=0), + ), migrations.CreateModel( - name="ValidationParams", + name="ValidationLayout", fields=[ ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ( "mode", - models.CharField( - choices=[("gt", "GT"), ("gt_pool", "GT_POOL")], max_length=32 - ), + models.CharField(choices=[("gt", "GT"), ("gt_pool", "GT_POOL")], max_length=32), ), ( "frame_selection_method", models.CharField( - choices=[ - ("random_uniform", "RANDOM_UNIFORM"), - ("manual", "MANUAL"), - ], + choices=[("random_uniform", "RANDOM_UNIFORM"), ("manual", "MANUAL")], max_length=32, ), ), @@ -44,38 +46,33 @@ class Migration(migrations.Migration): ("frames_percent", models.FloatField(null=True)), ("frames_per_job_count", models.IntegerField(null=True)), ("frames_per_job_percent", models.FloatField(null=True)), + ( + "task_data", + models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="validation_layout", + to="engine.data", + ), + ), ], ), migrations.CreateModel( - name="ValidationImage", + name="ValidationFrame", fields=[ ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("path", models.CharField(default="", max_length=1024)), ( - "validation_params", + "validation_layout", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="engine.validationparams", + on_delete=django.db.models.deletion.CASCADE, to="engine.validationlayout" ), ), ], ), - migrations.AddField( - model_name="data", - name="validation_params", - field=models.OneToOneField( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="task_data", - to="engine.validationparams", - ), - ), ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 5568c261782c..bd3896433372 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -230,23 +230,26 @@ def choices(cls): def __str__(self): return self.value -class ValidationParams(models.Model): +class ValidationLayout(models.Model): + task_data = models.OneToOneField( + 'Data', on_delete=models.CASCADE, related_name="validation_layout" + ) + mode = models.CharField(max_length=32, choices=ValidationMode.choices()) - # TODO: consider other storage options and ways to pass the parameters frame_selection_method = models.CharField( max_length=32, choices=JobFrameSelectionMethod.choices() ) random_seed = models.IntegerField(null=True) - frames: list[ValidationImage] + frames: models.manager.RelatedManager[ValidationFrame] frames_count = models.IntegerField(null=True) frames_percent = models.FloatField(null=True) frames_per_job_count = models.IntegerField(null=True) frames_per_job_percent = models.FloatField(null=True) -class ValidationImage(models.Model): - validation_params = models.ForeignKey(ValidationParams, on_delete=models.CASCADE) +class ValidationFrame(models.Model): + validation_layout = models.ForeignKey(ValidationLayout, on_delete=models.CASCADE) path = models.CharField(max_length=1024, default='') class Data(models.Model): @@ -265,9 +268,7 @@ class Data(models.Model): cloud_storage = models.ForeignKey('CloudStorage', on_delete=models.SET_NULL, null=True, related_name='data') sorting_method = models.CharField(max_length=15, choices=SortingMethod.choices(), default=SortingMethod.LEXICOGRAPHICAL) deleted_frames = IntArrayField(store_sorted=True, unique_values=True) - validation_params: Optional[ValidationParams] = models.OneToOneField( - 'ValidationParams', on_delete=models.CASCADE, null=True, related_name="task_data" - ) + validation_layout: ValidationLayout class Meta: default_permissions = () diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index a36cd1ade4dd..49193fd612a3 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -929,9 +929,11 @@ def __init__(self, *args, **kwargs): kwargs.setdefault('help_text', textwrap.dedent(__class__.__doc__)) super().__init__(*args, **kwargs) -class ValidationParamsSerializer(serializers.Serializer): +class ValidationLayoutParamsSerializer(serializers.Serializer): mode = serializers.ChoiceField(choices=models.ValidationMode.choices(), required=True) - frame_selection_method = serializers.ChoiceField(choices=models.JobFrameSelectionMethod.choices(), required=True) + frame_selection_method = serializers.ChoiceField( + choices=models.JobFrameSelectionMethod.choices(), required=True + ) frames = serializers.ListSerializer( child=serializers.CharField(max_length=MAX_FILENAME_LENGTH), default=[], required=False, allow_null=True @@ -942,7 +944,6 @@ class ValidationParamsSerializer(serializers.Serializer): frames_per_job_count = serializers.IntegerField(required=False, allow_null=True) frames_per_job_percent = serializers.FloatField(required=False, allow_null=True) - # def validate(self, attrs): # if attrs['mode'] == models.ValidationMode.GT: # if not ( @@ -964,17 +965,16 @@ class ValidationParamsSerializer(serializers.Serializer): # ): # return super().validate(attrs) - @transaction.atomic def create(self, validated_data): frames = validated_data.pop('frames', None) - instance = models.ValidationParams(**validated_data) + instance = models.ValidationLayout(**validated_data) instance.save() if frames: - models.ValidationImage.objects.bulk_create( - { "validation_params_id": instance.id, "path": frame } + models.ValidationFrame.objects.bulk_create( + { "validation_layout": instance, "path": frame } for frame in frames ) @@ -993,8 +993,8 @@ def update(self, instance, validated_data): for db_frame in instance.frames.all(): db_frame.delete() - models.ValidationImage.objects.bulk_create( - { "validation_params_id": instance.id, "path": frame } + models.ValidationFrame.objects.bulk_create( + { "validation_layout": instance, "path": frame } for frame in frames ) @@ -1086,7 +1086,7 @@ class DataSerializer(serializers.ModelSerializer): pass the list of file names in the required order. """.format(models.SortingMethod.PREDEFINED)) ) - validation_params = ValidationParamsSerializer(allow_null=True, required=False) + validation_params = ValidationLayoutParamsSerializer(allow_null=True, required=False) class Meta: model = models.Data @@ -1153,8 +1153,15 @@ def validate(self, attrs): if filename_pattern and server_files_exclude: raise serializers.ValidationError('The filename_pattern and server_files_exclude cannot be used together') + validation_params = attrs.pop('validation_params', None) + if validation_params: + validation_params_serializer = ValidationLayoutParamsSerializer(data=validation_params) + validation_params_serializer.is_valid(raise_exception=True) + attrs['validation_params'] = validation_params_serializer.validated_data + return attrs + @transaction.atomic def create(self, validated_data): files = self._pop_data(validated_data) @@ -1164,32 +1171,43 @@ def create(self, validated_data): self._create_files(db_data, files) db_data.save() + + validation_params = validated_data.pop('validation_params', None) + if validation_params.get("mode"): + validation_params["task_data"] = db_data + validation_layout_params_serializer = ValidationLayoutParamsSerializer() + validation_layout_params_serializer.create(validation_params) + return db_data + @transaction.atomic def update(self, instance, validated_data): - files = self._pop_data(validated_data) validation_params = validated_data.pop('validation_params', None) + + files = self._pop_data(validated_data) for key, value in validated_data.items(): setattr(instance, key, value) self._create_files(instance, files) + instance.save() + if validation_params: - db_validation_params = instance.validation_params - validation_params_serializer = ValidationParamsSerializer( - instance=db_validation_params, data=validation_params + db_validation_layout = getattr(instance, "validation_layout", None) + validation_layout_params_serializer = ValidationLayoutParamsSerializer( + instance=db_validation_layout ) - if not db_validation_params: - db_validation_params = validation_params_serializer.create( + if not db_validation_layout: + validation_params["task_data"] = instance + db_validation_layout = validation_layout_params_serializer.create( validation_params ) else: - db_validation_params = validation_params_serializer.update( - db_validation_params, validation_params + db_validation_layout = validation_layout_params_serializer.update( + db_validation_layout, validation_params ) - instance.validation_params = db_validation_params + instance.validation_layout = db_validation_layout - instance.save() return instance # pylint: disable=no-self-use @@ -1237,7 +1255,9 @@ class TaskReadSerializer(serializers.ModelSerializer): source_storage = StorageSerializer(required=False, allow_null=True) jobs = JobsSummarySerializer(url_filter_key='task_id', source='segment_set') labels = LabelsSummarySerializer(source='*') - validation_mode = serializers.CharField(source='validation_params.mode', required=False, allow_null=True) + validation_mode = serializers.CharField( + source='data.validation_layout.mode', required=False, allow_null=True + ) class Meta: model = models.Task diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 6a250ea7194c..94bec0ed3b4a 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -39,7 +39,7 @@ ) from cvat.apps.engine.models import RequestAction, RequestTarget from cvat.apps.engine.utils import ( - av_scan_paths,get_rq_job_meta, define_dependent_job, get_rq_lock_by_user, preload_images + av_scan_paths, format_list,get_rq_job_meta, define_dependent_job, get_rq_lock_by_user, preload_images ) from cvat.apps.engine.rq_job_handler import RQId from cvat.utils.http import make_requests_session, PROXIES_FOR_UNTRUSTED_URLS @@ -1118,104 +1118,124 @@ def _update_status(msg: str) -> None: ) ) - # TODO: + # TODO: refactor, support regular gt job # Prepare jobs - # frame_idx_map = None - # if validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: - # if db_task.mode != 'annotation': - # raise ValidationError("gt pool can only be used with 'annotation' mode tasks") - - # # TODO: handle other input variants - # seed = validation_params["random_seed"] - # frames_count = validation_params["frames_count"] - # frames_per_job_count = validation_params["frames_per_job_count"] - - # # 1. select pool frames - # # The RNG backend must not change to yield reproducible results, - # # so here we specify it explicitly - # from numpy import random - # rng = random.Generator(random.MT19937(seed=seed)) - - # all_frames = range(len(images)) - # pool_frames: list[int] = rng.choice( - # all_frames, size=frames_count, shuffle=False, replace=False - # ).tolist() - # non_pool_frames = set(all_frames).difference(pool_frames) - - # # 2. distribute pool frames - # from datumaro.util import take_by - - # # Allocate frames for jobs - # job_file_mapping: JobFileMapping = [] - # new_db_images: list[models.Image] = [] - # validation_frames: list[int] = [] - # frame_idx_map: dict[int, int] = {} # new to original id - # for job_frames in take_by(non_pool_frames, count=db_task.segment_size or db_data.size): - # job_validation_frames = rng.choice(pool_frames, size=frames_per_job_count, replace=False) - # job_frames += job_validation_frames.tolist() - - # random.shuffle(job_frames) # don't use the same rng - - # job_images = [] - # for job_frame in job_frames: - # # Insert placeholder frames into the frame sequence and shift frame ids - # image = images[job_frame] - # image = models.Image( - # data=db_data, **deepcopy(model_to_dict(image, exclude=["data"])) - # ) - # image.frame = len(new_db_images) - - # if job_frame in job_validation_frames: - # image.is_placeholder = True - # image.real_frame_id = job_frame - # validation_frames.append(image.frame) - - # job_images.append(image.path) - # new_db_images.append(image) - # frame_idx_map[image.frame] = job_frame - - # job_file_mapping.append(job_images) - - # # Append pool frames in the end, shift their ids, establish placeholder pointers - # frame_id_map: dict[int, int] = {} # original to new id - # for pool_frame in pool_frames: - # # Insert placeholder frames into the frame sequence and shift frame ids - # image = images[pool_frame] - # image = models.Image( - # data=db_data, **deepcopy(model_to_dict(image, exclude=["data"])) - # ) - # new_frame_id = len(new_db_images) - # image.frame = new_frame_id - - # frame_id_map[pool_frame] = new_frame_id - - # new_db_images.append(image) - # frame_idx_map[image.frame] = pool_frame - - # pool_frames = [frame_id_map[i] for i in pool_frames if i in frame_id_map] - - # # Store information about the real frame placement in the validation frames - # for validation_frame in validation_frames: - # image = new_db_images[validation_frame] - # assert image.is_placeholder - # image.real_frame_id = frame_id_map[image.real_frame_id] # TODO: maybe not needed - - # db_data.size = len(new_db_images) - # images = new_db_images - - # # Update manifest - # if task_mode == "annotation" and frame_idx_map: - # manifest = ImageManifestManager(db_data.get_manifest_path()) - # manifest.link( - # sources=[extractor.get_path(frame_idx_map[image.frame]) for image in images], - # meta={ - # k: {'related_images': related_images[k] } - # for k in related_images - # }, - # data_dir=upload_dir, - # DIM_3D=(db_task.dimension == models.DimensionType.DIM_3D), - # ) - # manifest.create() + if validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: + if db_task.mode != 'annotation': + raise ValidationError("gt pool can only be used with 'annotation' mode tasks") + + # 1. select pool frames + all_frames = range(len(images)) + + # The RNG backend must not change to yield reproducible frame picks, + # so here we specify it explicitly + from numpy import random + seed = validation_params["random_seed"] + rng = random.Generator(random.MT19937(seed=seed)) + + match validation_params["frame_selection_method"]: + case models.JobFrameSelectionMethod.RANDOM_UNIFORM: + frames_count = validation_params["frames_count"] + + pool_frames: list[int] = rng.choice( + all_frames, size=frames_count, shuffle=False, replace=False + ).tolist() + case models.JobFrameSelectionMethod.MANUAL: + pool_frames: list[int] = [] + + known_frame_names = {frame.path: frame.frame for frame in images} + unknown_requested_frames = [] + for frame_name in db_data.validation_layout.frames.all(): + frame_id = known_frame_names.get(frame_name) + if frame_id is None: + unknown_requested_frames.append(frame_name) + continue + + pool_frames.append(frame_id) + + if unknown_requested_frames: + raise ValidationError("Unknown validation frames requested: {}".format( + format_list(unknown_requested_frames)) + ) + case _: + assert False + + non_pool_frames = set(all_frames).difference(pool_frames) + + # 2. distribute pool frames + from datumaro.util import take_by + frames_per_job_count = validation_params["frames_per_job_count"] + + # Allocate frames for jobs + job_file_mapping: JobFileMapping = [] + new_db_images: list[models.Image] = [] + validation_frames: list[int] = [] + frame_idx_map: dict[int, int] = {} # new to original id + for job_frames in take_by(non_pool_frames, count=db_task.segment_size or db_data.size): + job_validation_frames = rng.choice(pool_frames, size=frames_per_job_count, replace=False) + job_frames += job_validation_frames.tolist() + + random.shuffle(job_frames) # don't use the same rng + + job_images = [] + for job_frame in job_frames: + # Insert placeholder frames into the frame sequence and shift frame ids + image = images[job_frame] + image = models.Image( + data=db_data, **deepcopy(model_to_dict(image, exclude=["data"])) + ) + image.frame = len(new_db_images) + + if job_frame in job_validation_frames: + image.is_placeholder = True + image.real_frame_id = job_frame + validation_frames.append(image.frame) + + job_images.append(image.path) + new_db_images.append(image) + frame_idx_map[image.frame] = job_frame + + job_file_mapping.append(job_images) + + # Append pool frames in the end, shift their ids, establish placeholder pointers + frame_id_map: dict[int, int] = {} # original to new id + for pool_frame in pool_frames: + # Insert placeholder frames into the frame sequence and shift frame ids + image = images[pool_frame] + image = models.Image( + data=db_data, **deepcopy(model_to_dict(image, exclude=["data"])) + ) + new_frame_id = len(new_db_images) + image.frame = new_frame_id + + frame_id_map[pool_frame] = new_frame_id + + new_db_images.append(image) + frame_idx_map[image.frame] = pool_frame + + pool_frames = [frame_id_map[i] for i in pool_frames if i in frame_id_map] + + # Store information about the real frame placement in the validation frames + for validation_frame in validation_frames: + image = new_db_images[validation_frame] + assert image.is_placeholder + image.real_frame_id = frame_id_map[image.real_frame_id] # TODO: maybe not needed + + images = new_db_images + db_data.size = len(images) + + # Update manifest + manifest = ImageManifestManager(db_data.get_manifest_path()) + manifest.link( + sources=[extractor.get_path(frame_idx_map[image.frame]) for image in images], + meta={ + k: {'related_images': related_images[k] } + for k in related_images + }, + data_dir=upload_dir, + DIM_3D=(db_task.dimension == models.DimensionType.DIM_3D), + ) + manifest.create() if db_task.mode == 'annotation': models.Image.objects.bulk_create(images) @@ -1250,20 +1270,20 @@ def _update_status(msg: str) -> None: _create_segments_and_jobs(db_task, job_file_mapping=job_file_mapping) - # TODO: - # if validation_params: - # db_gt_segment = models.Segment( - # task=db_task, - # start_frame=0, - # stop_frame=db_data.stop_frame, - # frames=pool_frames, - # type=models.SegmentType.SPECIFIC_FRAMES, - # ) - # db_gt_segment.save() - - # db_gt_job = models.Job(segment=db_gt_segment, type=models.JobType.GROUND_TRUTH) - # db_gt_job.save() - # db_gt_job.make_dirs() + # TODO: refactor, support simple gt + if validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: + db_gt_segment = models.Segment( + task=db_task, + start_frame=0, + stop_frame=db_data.stop_frame, + frames=pool_frames, + type=models.SegmentType.SPECIFIC_FRAMES, + ) + db_gt_segment.save() + + db_gt_job = models.Job(segment=db_gt_segment, type=models.JobType.GROUND_TRUTH) + db_gt_job.save() + db_gt_job.make_dirs() if ( settings.MEDIA_CACHE_ALLOW_STATIC_CACHE and @@ -1376,18 +1396,6 @@ def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: else: media_iterator = RandomAccessIterator(media_extractor) - # if db_task.mode == "annotation" and frame_idx_map: - # generator = ( - # ( - # extractor.get_image(frame_idx_map[image.frame]), - # extractor.get_path(frame_idx_map[image.frame]), - # image.frame, - # ) - # for image in images - # ) - # else: - # generator = extractor - with closing(media_iterator): progress_updater = _ChunkProgressUpdater() @@ -1407,7 +1415,6 @@ def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: ( # Convert absolute to relative ids (extractor output positions) # Extractor will skip frames outside requested - # TODO: handle placeholder frames (abs_frame_id - db_data.start_frame) // frame_step for abs_frame_id in db_segment.frame_set ), diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index a55771caffe6..56fa3819641f 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -2138,8 +2138,8 @@ def honeypot(self, request, pk): ).get(pk=pk) if ( - not db_job.segment.task.data.validation_params or - db_job.segment.task.data.validation_params.mode != models.ValidationMode.GT_POOL + not db_job.segment.task.data.validation_layout or + db_job.segment.task.data.validation_layout.mode != models.ValidationMode.GT_POOL ): raise ValidationError("Honeypots are not configured in the task") diff --git a/cvat/schema.yml b/cvat/schema.yml index 37ad2d7dd871..0e04cf0df3ab 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -7510,7 +7510,7 @@ components: pass the list of file names in the required order. validation_params: allOf: - - $ref: '#/components/schemas/ValidationParamsRequest' + - $ref: '#/components/schemas/ValidationLayoutParamsRequest' nullable: true required: - image_quality @@ -10839,15 +10839,7 @@ components: maxLength: 150 required: - username - ValidationMode: - enum: - - gt - - gt_pool - type: string - description: |- - * `gt` - GT - * `gt_pool` - GT_POOL - ValidationParamsRequest: + ValidationLayoutParamsRequest: type: object properties: mode: @@ -10881,6 +10873,14 @@ components: required: - frame_selection_method - mode + ValidationMode: + enum: + - gt + - gt_pool + type: string + description: |- + * `gt` - GT + * `gt_pool` - GT_POOL WebhookContentType: enum: - application/json From de1bf68913c0b38e4e1db747dcd4e2c85a6756b3 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 26 Aug 2024 18:49:52 +0300 Subject: [PATCH 098/227] Support task creation with GT job, add request validation --- cvat/apps/engine/cache.py | 25 ++-- cvat/apps/engine/frame_provider.py | 4 +- ...laceholder_image_real_frame_id_and_more.py | 13 +- cvat/apps/engine/models.py | 5 +- cvat/apps/engine/serializers.py | 111 ++++++++++++---- cvat/apps/engine/task.py | 125 +++++++++++++++--- 6 files changed, 215 insertions(+), 68 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 4b0a7b720bda..bb54feec5dfb 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -100,9 +100,9 @@ def create_item() -> _CacheItem: def _delete_cache_item(self, key: str): try: self._cache.delete(key) - slogger.glob.info(f'Removed chunk from the cache: key {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) + slogger.glob.error(f"Failed to remove item from the cache: key {key}", exc_info=True) def _get(self, key: str) -> Optional[DataWithMime]: slogger.glob.info(f"Starting to get chunk from cache: key {key}") @@ -125,9 +125,7 @@ def _make_segment_chunk_key( ) -> str: return f"segment_{db_segment.id}_{chunk_number}_{quality}" - def _make_cloud_storage_preview_key( - self, db_cloud_storage: models.CloudStorage - ) -> str: + def _make_cloud_storage_preview_key(self, db_cloud_storage: models.CloudStorage) -> str: return f"cloudstorage_preview_{db_cloud_storage.id}" def get_segment_chunk( @@ -175,9 +173,11 @@ def get_or_set_segment_preview(self, db_segment: models.Segment) -> DataWithMime ) def remove_segment_chunk(self, db_segment: models.Segment, chunk_number: str, *, quality: str): - self._delete_cache_item(self._make_segment_chunk_key( - db_segment=db_segment, chunk_number=chunk_number, quality=quality - )) + self._delete_cache_item( + self._make_segment_chunk_key( + db_segment=db_segment, chunk_number=chunk_number, quality=quality + ) + ) def get_cloud_preview(self, db_storage: models.CloudStorage) -> Optional[DataWithMime]: return self._get(self._make_cloud_storage_preview_key(db_storage)) @@ -385,8 +385,9 @@ def get_frames(): ) as read_frame_iter: for frame_idx in range(db_data.chunk_size): frame_idx = ( - db_data.start_frame + - chunk_number * db_data.chunk_size + frame_idx * frame_step + db_data.start_frame + + chunk_number * db_data.chunk_size + + frame_idx * frame_step ) if db_data.stop_frame < frame_idx: break @@ -494,9 +495,7 @@ def prepare_context_images( return None, None with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file: - common_path = os.path.commonpath( - list(map(lambda x: str(x.path), related_files)) - ) + common_path = os.path.commonpath(list(map(lambda x: str(x.path), related_files))) for related_file in related_files: path = os.path.realpath(str(related_file.path)) name = os.path.relpath(str(related_file.path), common_path) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index f5d5a48ed521..0e607999785f 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -280,7 +280,7 @@ def get_chunk( if not task_chunk_frame_set.isdisjoint(s.frame_set) ], key=lambda s: ( - s.type != models.SegmentType.RANGE, # prioritize RANGE segments, + s.type != models.SegmentType.RANGE, # prioritize RANGE segments, s.start_frame, ), ) @@ -382,7 +382,7 @@ def _get_segment(self, validated_frame_number: int) -> models.Segment: s for s in sorted( self._db_task.segment_set.all(), - key=lambda s: s.type != models.SegmentType.RANGE # prioritize RANGE segments + key=lambda s: s.type != models.SegmentType.RANGE, # prioritize RANGE segments ) if abs_frame_number in s.frame_set ) diff --git a/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py b/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py index e27d399185e0..1d71001ea735 100644 --- a/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py +++ b/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-08-23 16:13 +# Generated by Django 4.2.15 on 2024-08-26 15:46 from django.db import migrations, models import django.db.models.deletion @@ -37,7 +37,11 @@ class Migration(migrations.Migration): ( "frame_selection_method", models.CharField( - choices=[("random_uniform", "RANDOM_UNIFORM"), ("manual", "MANUAL")], + choices=[ + ("random_uniform", "RANDOM_UNIFORM"), + ("random_per_job", "RANDOM_PER_JOB"), + ("manual", "MANUAL"), + ], max_length=32, ), ), @@ -49,7 +53,6 @@ class Migration(migrations.Migration): ( "task_data", models.OneToOneField( - null=True, on_delete=django.db.models.deletion.CASCADE, related_name="validation_layout", to="engine.data", @@ -70,7 +73,9 @@ class Migration(migrations.Migration): ( "validation_layout", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="engine.validationlayout" + on_delete=django.db.models.deletion.CASCADE, + related_name="frames", + to="engine.validationlayout", ), ), ], diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index bd3896433372..c7792dd32a23 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -173,6 +173,7 @@ def __str__(self): class JobFrameSelectionMethod(str, Enum): RANDOM_UNIFORM = 'random_uniform' + RANDOM_PER_JOB = 'random_per_job' MANUAL = 'manual' @classmethod @@ -249,7 +250,9 @@ class ValidationLayout(models.Model): frames_per_job_percent = models.FloatField(null=True) class ValidationFrame(models.Model): - validation_layout = models.ForeignKey(ValidationLayout, on_delete=models.CASCADE) + validation_layout = models.ForeignKey( + ValidationLayout, on_delete=models.CASCADE, related_name="frames" + ) path = models.CharField(max_length=1024, default='') class Data(models.Model): diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 49193fd612a3..7730eb8b7182 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -14,7 +14,7 @@ from tempfile import NamedTemporaryFile import textwrap -from typing import Any, Dict, Iterable, Optional, OrderedDict, Union +from typing import Any, Dict, Iterable, Optional, OrderedDict, Sequence, Union from rq.job import Job as RQJob, JobStatus as RQJobStatus from datetime import timedelta @@ -929,6 +929,12 @@ def __init__(self, *args, **kwargs): kwargs.setdefault('help_text', textwrap.dedent(__class__.__doc__)) super().__init__(*args, **kwargs) +def _validate_percent(value: float) -> float: + if not (0 <= value <= 1): + raise serializers.ValidationError("Value must be in the range [0; 1]") + + return value + class ValidationLayoutParamsSerializer(serializers.Serializer): mode = serializers.ChoiceField(choices=models.ValidationMode.choices(), required=True) frame_selection_method = serializers.ChoiceField( @@ -938,32 +944,81 @@ class ValidationLayoutParamsSerializer(serializers.Serializer): child=serializers.CharField(max_length=MAX_FILENAME_LENGTH), default=[], required=False, allow_null=True ) - frames_count = serializers.IntegerField(required=False, allow_null=True) - frames_percent = serializers.FloatField(required=False, allow_null=True) + frames_count = serializers.IntegerField(required=False, allow_null=True, min_value=1) + frames_percent = serializers.FloatField( + required=False, allow_null=True, validators=[_validate_percent] + ) random_seed = serializers.IntegerField(required=False, allow_null=True) - frames_per_job_count = serializers.IntegerField(required=False, allow_null=True) - frames_per_job_percent = serializers.FloatField(required=False, allow_null=True) - - # def validate(self, attrs): - # if attrs['mode'] == models.ValidationMode.GT: - # if not ( - # ( - # attrs['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_UNIFORM - # and ( - # attrs.get('frames_count') is not None - # or attrs.get('frames_percent') is not None - # ) - # and not attrs.get('frames') - # ) - # ^ - # ( - # ['frame_selection_method'] == models.JobFrameSelectionMethod.MANUAL - # and not attrs.get('frames') - # and attrs.get('frames_count') is None - # and attrs.get('frames_percent') is None - # ) - # ): - # return super().validate(attrs) + frames_per_job_count = serializers.IntegerField(required=False, allow_null=True, min_value=1) + frames_per_job_percent = serializers.FloatField( + required=False, allow_null=True, validators=[_validate_percent] + ) + + def validate(self, attrs): + def drop_none_keys( + d: dict[str, Any], *, keys: Optional[Sequence[str]] = None + ) -> dict[str, Any]: + if keys is None: + keys = d.keys() + return {k: v for k, v in d.items() if k in keys and v is not None} + + def require_one_of_fields(keys: Sequence[str]) -> None: + notset = object() + + active_count = sum(attrs.get(key, notset) is not notset for key in keys) + if active_count == 1: + return + + options = ', '.join(f'"{k}"' for k in keys) + + if not active_count: + raise serializers.ValidationError(f"One of the fields {options} required") + else: + raise serializers.ValidationError(f"Only 1 of the fields {options} can be used") + + def require_one_of_values(key: str, values: Sequence[Any]) -> None: + if attrs[key] not in values: + raise serializers.ValidationError('"{}" must be one of {}'.format( + key, + ', '.join(f"{k}" for k in values) + )) + + attrs = drop_none_keys(attrs) + + if attrs["mode"] == models.ValidationMode.GT: + require_one_of_values("frame_selection_method", [ + models.JobFrameSelectionMethod.MANUAL, + models.JobFrameSelectionMethod.RANDOM_UNIFORM, + models.JobFrameSelectionMethod.RANDOM_PER_JOB, + ]) + elif attrs["mode"] == models.ValidationMode.GT_POOL: + require_one_of_values("frame_selection_method", [ + models.JobFrameSelectionMethod.MANUAL, + models.JobFrameSelectionMethod.RANDOM_UNIFORM, + ]) + require_one_of_fields(['frames_per_job_count', 'frames_per_job_percent']) + else: + assert False, f"Unknown validation mode {attrs['mode']}" + + if attrs['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_UNIFORM: + require_one_of_fields(['frames_count', 'frames_percent']) + elif attrs['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_PER_JOB: + require_one_of_fields(['frames_per_job_count', 'frames_per_job_percent']) + elif attrs['frame_selection_method'] == models.JobFrameSelectionMethod.MANUAL: + if not attrs.get('frames'): + raise serializers.ValidationError('The "frames" field is required') + + if ( + attrs['frame_selection_method'] != models.JobFrameSelectionMethod.MANUAL and + attrs.get('frames') + ): + raise serializers.ValidationError( + '"frames" can only be used when "frame_selection_method" is "{}"'.format( + models.JobFrameSelectionMethod.MANUAL + ) + ) + + return super().validate(attrs) @transaction.atomic def create(self, validated_data): @@ -974,7 +1029,7 @@ def create(self, validated_data): if frames: models.ValidationFrame.objects.bulk_create( - { "validation_layout": instance, "path": frame } + models.ValidationFrame(validation_layout=instance, path=frame) for frame in frames ) @@ -994,7 +1049,7 @@ def update(self, instance, validated_data): db_frame.delete() models.ValidationFrame.objects.bulk_create( - { "validation_layout": instance, "path": frame } + models.ValidationFrame(validation_layout=instance, path=frame) for frame in frames ) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 94bec0ed3b4a..d0323d053fe7 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -343,27 +343,36 @@ def _validate_job_file_mapping( def _validate_validation_params( db_task: models.Task, data: Dict[str, Any] ) -> Optional[dict[str, Any]]: - validation_params = data.get('validation_params', {}) - if not validation_params: + params = data.get('validation_params', {}) + if not params: return None - if validation_params['mode'] != models.ValidationMode.GT_POOL: - return validation_params + if ( + params['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_PER_JOB or + params['mode'] == models.ValidationMode.GT_POOL + ) and (frames_per_job := params.get('frames_per_job_count')): + if db_task.segment_size <= frames_per_job: + raise ValidationError( + "Validation frame count per job cannot be greater than segment size" + ) + + if params['mode'] != models.ValidationMode.GT_POOL: + return params if data.get('sorting_method', db_task.data.sorting_method) != models.SortingMethod.RANDOM: - raise ValidationError("validation mode '{}' can only be used with '{}' sorting".format( + raise ValidationError('validation mode "{}" can only be used with "{}" sorting'.format( models.ValidationMode.GT_POOL.value, models.SortingMethod.RANDOM.value, )) for incompatible_key in ['job_file_mapping', 'overlap']: if data.get(incompatible_key): - raise ValidationError("validation mode '{}' cannot be used with '{}'".format( + raise ValidationError('validation mode "{}" cannot be used with "{}"'.format( models.ValidationMode.GT_POOL.value, incompatible_key, )) - return validation_params + return params def _validate_manifest( manifests: List[str], @@ -1118,7 +1127,7 @@ def _update_status(msg: str) -> None: ) ) - # TODO: refactor, support regular gt job + # TODO: refactor # Prepare jobs if validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: if db_task.mode != 'annotation': @@ -1130,25 +1139,29 @@ def _update_status(msg: str) -> None: # The RNG backend must not change to yield reproducible frame picks, # so here we specify it explicitly from numpy import random - seed = validation_params["random_seed"] + seed = validation_params.get("random_seed") rng = random.Generator(random.MT19937(seed=seed)) + pool_frames: list[int] = [] match validation_params["frame_selection_method"]: case models.JobFrameSelectionMethod.RANDOM_UNIFORM: frames_count = validation_params["frames_count"] + if len(images) < frames_count: + raise ValidationError( + f"The number of validation frames requested ({frames_count})" + f"is greater that the number of task frames ({len(images)})" + ) - pool_frames: list[int] = rng.choice( + pool_frames = rng.choice( all_frames, size=frames_count, shuffle=False, replace=False ).tolist() case models.JobFrameSelectionMethod.MANUAL: - pool_frames: list[int] = [] - known_frame_names = {frame.path: frame.frame for frame in images} unknown_requested_frames = [] - for frame_name in db_data.validation_layout.frames.all(): - frame_id = known_frame_names.get(frame_name) + for frame in db_data.validation_layout.frames.all(): + frame_id = known_frame_names.get(frame.path) if frame_id is None: - unknown_requested_frames.append(frame_name) + unknown_requested_frames.append(frame.path) continue pool_frames.append(frame_id) @@ -1164,7 +1177,15 @@ def _update_status(msg: str) -> None: # 2. distribute pool frames from datumaro.util import take_by - frames_per_job_count = validation_params["frames_per_job_count"] + + if validation_params.get("frames_per_job_count"): + frames_per_job_count = validation_params["frames_per_job_count"] + elif validation_params.get("frames_per_job_percent"): + frames_per_job_count = max( + 1, int(validation_params["frames_per_job_percent"] * db_task.segment_size) + ) + else: + raise ValidationError("The number of validation frames is not specified") # Allocate frames for jobs job_file_mapping: JobFileMapping = [] @@ -1237,6 +1258,8 @@ def _update_status(msg: str) -> None: ) manifest.create() + validation_frames = pool_frames + if db_task.mode == 'annotation': models.Image.objects.bulk_create(images) images = models.Image.objects.filter(data_id=db_data.id) @@ -1249,7 +1272,6 @@ def _update_status(msg: str) -> None: ) for image in images for related_file_path in related_images.get(image.path, []) - if not image.is_placeholder # TODO ] models.RelatedFile.objects.bulk_create(db_related_files) else: @@ -1270,13 +1292,76 @@ def _update_status(msg: str) -> None: _create_segments_and_jobs(db_task, job_file_mapping=job_file_mapping) - # TODO: refactor, support simple gt - if validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: + if validation_params and validation_params['mode'] == models.ValidationMode.GT: + # The RNG backend must not change to yield reproducible frame picks, + # so here we specify it explicitly + from numpy import random + seed = validation_params.get("random_seed") + rng = random.Generator(random.MT19937(seed=seed)) + + validation_frames: list[int] = [] + match validation_params["frame_selection_method"]: + case models.JobFrameSelectionMethod.RANDOM_UNIFORM: + all_frames = range(len(images)) + + if validation_params.get("frames_count"): + frames_count = validation_params["frames_count"] + if len(images) < frames_count: + raise ValidationError( + f"The number of validation frames requested ({frames_count})" + f"is greater that the number of task frames ({len(images)})" + ) + elif validation_params.get("frames_percent"): + frames_count = max( + 1, int(validation_params["frames_percent"] * len(all_frames)) + ) + else: + raise ValidationError("The number of validation frames is not specified") + + validation_frames = rng.choice( + all_frames, size=frames_count, shuffle=False, replace=False + ).tolist() + case models.JobFrameSelectionMethod.RANDOM_PER_JOB: + if validation_params.get("frames_per_job_count"): + frames_count = validation_params["frames_per_job_count"] + elif validation_params.get("frames_per_job_percent"): + frames_count = max( + 1, int(validation_params["frames_per_job_percent"] * db_task.segment_size) + ) + else: + raise ValidationError("The number of validation frames is not specified") + + for segment in db_task.segment_set.all(): + validation_frames.extend(rng.choice( + list(segment.frame_set), size=frames_count, shuffle=False, replace=False + ).tolist()) + case models.JobFrameSelectionMethod.MANUAL: + known_frame_names = {frame.path: frame.frame for frame in images} + unknown_requested_frames = [] + for frame in db_data.validation_layout.frames.all(): + frame_id = known_frame_names.get(frame.path) + if frame_id is None: + unknown_requested_frames.append(frame.path) + continue + + validation_frames.append(frame_id) + + if unknown_requested_frames: + raise ValidationError("Unknown validation frames requested: {}".format( + format_list(unknown_requested_frames)) + ) + case _: + assert False, ( + f'Unknown frame selection method {validation_params["frame_selection_method"]}' + ) + + # TODO: refactor + if validation_params: db_gt_segment = models.Segment( task=db_task, start_frame=0, stop_frame=db_data.stop_frame, - frames=pool_frames, + frames=validation_frames, type=models.SegmentType.SPECIFIC_FRAMES, ) db_gt_segment.save() From 1c32114fdf136ac63c2893e58aef2fb084adebaf Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 26 Aug 2024 18:51:34 +0300 Subject: [PATCH 099/227] Update server schema --- cvat/schema.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cvat/schema.yml b/cvat/schema.yml index 0e04cf0df3ab..1102c1f38bb3 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -7802,10 +7802,12 @@ components: FrameSelectionMethod: enum: - random_uniform + - random_per_job - manual type: string description: |- * `random_uniform` - RANDOM_UNIFORM + * `random_per_job` - RANDOM_PER_JOB * `manual` - MANUAL FunctionCall: type: object @@ -10855,6 +10857,7 @@ components: default: [] frames_count: type: integer + minimum: 1 nullable: true frames_percent: type: number @@ -10865,6 +10868,7 @@ components: nullable: true frames_per_job_count: type: integer + minimum: 1 nullable: true frames_per_job_percent: type: number From ab3066a62c4c749fd25751cac2239701aab250ff Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 26 Aug 2024 19:01:24 +0300 Subject: [PATCH 100/227] Remove extra files --- cvat/apps/honeypots/__init__.py | 0 cvat/apps/honeypots/apps.py | 17 --- cvat/apps/honeypots/migrations/__init__.py | 0 cvat/apps/honeypots/pyproject.toml | 12 -- cvat/apps/honeypots/reports.py | 29 ----- .../honeypots/rules/honeypot_reports.rego | 118 ------------------ .../honeypots/rules/honeypot_settings.rego | 104 --------------- cvat/apps/honeypots/serializers.py | 13 -- cvat/apps/honeypots/urls.py | 16 --- cvat/apps/honeypots/views.py | 64 ---------- 10 files changed, 373 deletions(-) delete mode 100644 cvat/apps/honeypots/__init__.py delete mode 100644 cvat/apps/honeypots/apps.py delete mode 100644 cvat/apps/honeypots/migrations/__init__.py delete mode 100644 cvat/apps/honeypots/pyproject.toml delete mode 100644 cvat/apps/honeypots/reports.py delete mode 100644 cvat/apps/honeypots/rules/honeypot_reports.rego delete mode 100644 cvat/apps/honeypots/rules/honeypot_settings.rego delete mode 100644 cvat/apps/honeypots/serializers.py delete mode 100644 cvat/apps/honeypots/urls.py delete mode 100644 cvat/apps/honeypots/views.py diff --git a/cvat/apps/honeypots/__init__.py b/cvat/apps/honeypots/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/cvat/apps/honeypots/apps.py b/cvat/apps/honeypots/apps.py deleted file mode 100644 index 487a127e8acf..000000000000 --- a/cvat/apps/honeypots/apps.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright (C) 2024 CVAT.ai Corporation -# -# SPDX-License-Identifier: MIT - -from django.apps import AppConfig - - -class HoneypotsConfig(AppConfig): - name = "cvat.apps.honeypots" - - def ready(self) -> None: - from cvat.apps.iam.permissions import load_app_permissions - - load_app_permissions(self) - - # Required to define signals in the application - from . import signals # pylint: disable=unused-import diff --git a/cvat/apps/honeypots/migrations/__init__.py b/cvat/apps/honeypots/migrations/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/cvat/apps/honeypots/pyproject.toml b/cvat/apps/honeypots/pyproject.toml deleted file mode 100644 index 567b78362580..000000000000 --- a/cvat/apps/honeypots/pyproject.toml +++ /dev/null @@ -1,12 +0,0 @@ -[tool.isort] -profile = "black" -forced_separate = ["tests"] -line_length = 100 -skip_gitignore = true # align tool behavior with Black -known_first_party = ["cvat"] - -# Can't just use a pyproject in the root dir, so duplicate -# https://github.com/psf/black/issues/2863 -[tool.black] -line-length = 100 -target-version = ['py38'] diff --git a/cvat/apps/honeypots/reports.py b/cvat/apps/honeypots/reports.py deleted file mode 100644 index 1c16302b3ac8..000000000000 --- a/cvat/apps/honeypots/reports.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (C) 2024 CVAT.ai Corporation -# -# SPDX-License-Identifier: MIT - -from dataclasses import dataclass -from typing import NewType - - -RqId = NewType("RqId", str) - - -{ - "validation_frame_ids": [1, 4, 5], - "inactive_validation_frames": [4], - "jobs": [ - { - "id": 1, - "validation_frames": [1, 4] - } - ], -} - - -class ReportManager: - def schedule_report_creation_job(self, task: int) -> RqId: - raise NotImplementedError - - def create_report(self, task: int) -> HoneypotsReport: - pass diff --git a/cvat/apps/honeypots/rules/honeypot_reports.rego b/cvat/apps/honeypots/rules/honeypot_reports.rego deleted file mode 100644 index 73d5ce3307bc..000000000000 --- a/cvat/apps/honeypots/rules/honeypot_reports.rego +++ /dev/null @@ -1,118 +0,0 @@ -package honeypots - -import rego.v1 - -import data.utils -import data.organizations - -# input: { -# "scope": <"view"|"list"|"create"|"view:status"> or null, -# "auth": { -# "user": { -# "id": , -# "privilege": <"admin"|"business"|"user"|"worker"> or null -# }, -# "organization": { -# "id": , -# "owner": { -# "id": -# }, -# "user": { -# "role": <"owner"|"maintainer"|"supervisor"|"worker"> or null -# } -# } or null, -# }, -# "resource": { -# "id": , -# "owner": { "id": }, -# "organization": { "id": } or null, -# "task": { -# "id": , -# "owner": { "id": }, -# "assignee": { "id": }, -# "organization": { "id": } or null, -# } or null, -# "project": { -# "id": , -# "owner": { "id": }, -# "assignee": { "id": }, -# "organization": { "id": } or null, -# } or null, -# } -# } - -default allow := false - -allow if { - utils.is_admin -} - -allow if { - input.scope == utils.LIST - utils.is_sandbox -} - -allow if { - input.scope == utils.LIST - organizations.is_member -} - -filter := [] if { # Django Q object to filter list of entries - utils.is_admin - utils.is_sandbox -} else := qobject if { - utils.is_admin - utils.is_organization - org := input.auth.organization - qobject := [ - {"job__segment__task__organization": org.id}, - {"job__segment__task__project__organization": org.id}, "|", - {"task__organization": org.id}, "|", - {"task__project__organization": org.id}, "|", - ] -} else := qobject if { - utils.is_sandbox - user := input.auth.user - qobject := [ - {"job__segment__task__owner_id": user.id}, - {"job__segment__task__assignee_id": user.id}, "|", - {"job__segment__task__project__owner_id": user.id}, "|", - {"job__segment__task__project__assignee_id": user.id}, "|", - {"task__owner_id": user.id}, "|", - {"task__assignee_id": user.id}, "|", - {"task__project__owner_id": user.id}, "|", - {"task__project__assignee_id": user.id}, "|", - ] -} else := qobject if { - utils.is_organization - utils.has_perm(utils.USER) - organizations.has_perm(organizations.MAINTAINER) - org := input.auth.organization - qobject := [ - {"job__segment__task__organization": org.id}, - {"job__segment__task__project__organization": org.id}, "|", - {"task__organization": org.id}, "|", - {"task__project__organization": org.id}, "|", - ] -} else := qobject if { - organizations.has_perm(organizations.WORKER) - user := input.auth.user - org := input.auth.organization - qobject := [ - {"job__segment__task__organization": org.id}, - {"job__segment__task__project__organization": org.id}, "|", - {"task__organization": org.id}, "|", - {"task__project__organization": org.id}, "|", - - {"job__segment__task__owner_id": user.id}, - {"job__segment__task__assignee_id": user.id}, "|", - {"job__segment__task__project__owner_id": user.id}, "|", - {"job__segment__task__project__assignee_id": user.id}, "|", - {"task__owner_id": user.id}, "|", - {"task__assignee_id": user.id}, "|", - {"task__project__owner_id": user.id}, "|", - {"task__project__assignee_id": user.id}, "|", - - "&" - ] -} diff --git a/cvat/apps/honeypots/rules/honeypot_settings.rego b/cvat/apps/honeypots/rules/honeypot_settings.rego deleted file mode 100644 index 820798c62384..000000000000 --- a/cvat/apps/honeypots/rules/honeypot_settings.rego +++ /dev/null @@ -1,104 +0,0 @@ -package honeypots - -import rego.v1 - -import data.utils -import data.organizations - -# input: { -# "scope": <"view"> or null, -# "auth": { -# "user": { -# "id": , -# "privilege": <"admin"|"business"|"user"|"worker"> or null -# }, -# "organization": { -# "id": , -# "owner": { -# "id": -# }, -# "user": { -# "role": <"owner"|"maintainer"|"supervisor"|"worker"> or null -# } -# } or null, -# }, -# "resource": { -# "id": , -# "owner": { "id": }, -# "organization": { "id": } or null, -# "task": { -# "id": , -# "owner": { "id": }, -# "assignee": { "id": }, -# "organization": { "id": } or null, -# } or null, -# "project": { -# "id": , -# "owner": { "id": }, -# "assignee": { "id": }, -# "organization": { "id": } or null, -# } or null, -# } -# } - -default allow := false - -allow if { - utils.is_admin -} - -allow if { - input.scope == utils.LIST - utils.is_sandbox -} - -allow if { - input.scope == utils.LIST - organizations.is_member -} - -filter := [] if { # Django Q object to filter list of entries - utils.is_admin - utils.is_sandbox -} else := qobject if { - utils.is_admin - utils.is_organization - org := input.auth.organization - qobject := [ - {"task__organization": org.id}, - {"task__project__organization": org.id}, "|", - ] -} else := qobject if { - utils.is_sandbox - user := input.auth.user - qobject := [ - {"task__owner_id": user.id}, - {"task__assignee_id": user.id}, "|", - {"task__project__owner_id": user.id}, "|", - {"task__project__assignee_id": user.id}, "|", - ] -} else := qobject if { - utils.is_organization - utils.has_perm(utils.USER) - organizations.has_perm(organizations.MAINTAINER) - org := input.auth.organization - qobject := [ - {"task__organization": org.id}, - {"task__project__organization": org.id}, "|", - ] -} else := qobject if { - organizations.has_perm(organizations.WORKER) - user := input.auth.user - org := input.auth.organization - qobject := [ - {"task__organization": org.id}, - {"task__project__organization": org.id}, "|", - - {"task__owner_id": user.id}, - {"task__assignee_id": user.id}, "|", - {"task__project__owner_id": user.id}, "|", - {"task__project__assignee_id": user.id}, "|", - - "&" - ] -} diff --git a/cvat/apps/honeypots/serializers.py b/cvat/apps/honeypots/serializers.py deleted file mode 100644 index 5846a0b70352..000000000000 --- a/cvat/apps/honeypots/serializers.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (C) 2024 CVAT.ai Corporation -# -# SPDX-License-Identifier: MIT - -from rest_framework import serializers - - -class HoneypotsReportCreateSerializer(serializers.Serializer): - task_id = serializers.IntegerField() - - -class HoneypotsReportSummarySerializer(serializers.Serializer): - pass diff --git a/cvat/apps/honeypots/urls.py b/cvat/apps/honeypots/urls.py deleted file mode 100644 index 758136b451a4..000000000000 --- a/cvat/apps/honeypots/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright (C) 2023 CVAT.ai Corporation -# -# SPDX-License-Identifier: MIT - -from django.urls import include, path -from rest_framework import routers - -from cvat.apps.honeypots import views - -router = routers.DefaultRouter(trailing_slash=False) -router.register("reports", views.HoneypotsReportViewSet, basename="honeypot_reports") - -urlpatterns = [ - # entry point for API - path("honeypots/", include(router.urls)), -] diff --git a/cvat/apps/honeypots/views.py b/cvat/apps/honeypots/views.py deleted file mode 100644 index 516df76f8242..000000000000 --- a/cvat/apps/honeypots/views.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (C) 2024 CVAT.ai Corporation -# -# SPDX-License-Identifier: MIT - -import textwrap - -from django.db.models import Q -from django.http import HttpResponse -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import ( - OpenApiParameter, - OpenApiResponse, - extend_schema, - extend_schema_view, -) -from rest_framework import mixins, status, viewsets -from rest_framework.decorators import action -from rest_framework.exceptions import NotFound, ValidationError -from rest_framework.response import Response - -from cvat.apps.engine.mixins import PartialUpdateModelMixin -from cvat.apps.engine.models import Task -from cvat.apps.engine.serializers import RqIdSerializer -from cvat.apps.engine.utils import get_server_url -from cvat.apps.honeypots.serializers import ( - HoneypotsReportCreateSerializer, - HoneypotsReportSummarySerializer, -) -from cvat.apps.honeypots.reports import ReportManager - - -@extend_schema(tags=["honeypots"]) -class HoneypotsReportViewSet( - viewsets.GenericViewSet, - mixins.CreateModelMixin, -): - # TODO: take from requests API - - def get_serializer_class(self): - # a separate method is required for drf-spectacular to work - return HoneypotsReportSummarySerializer - - @extend_schema( - operation_id="honeypots_create_report", - summary="Create a honeypots report", - request=HoneypotsReportCreateSerializer(required=False), - responses={ - "201": HoneypotsReportSummarySerializer, - "400": OpenApiResponse( - description="Invalid or failed request, check the response data for details" - ), - }, - ) - def create(self, request, *args, **kwargs): - self.check_permissions(request) - - request_serializer = HoneypotsReportCreateSerializer(data=request.data) - request_serializer.is_valid(raise_exception=True) - request_data = request_serializer.validated_data - - report_manager = ReportManager() - report = report_manager.create_report(task_id=request_data.task_id) - - return Response(report, status=status.HTTP_200_OK) From d3b58bc5918cfb051b9d7d015d723d5e654679f6 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 14:42:55 +0300 Subject: [PATCH 101/227] Add support for gt job creation with new params, rename percent to share --- cvat/apps/engine/field_validation.py | 45 +++++ cvat/apps/engine/serializers.py | 272 +++++++++++++++++++-------- cvat/apps/engine/task.py | 38 ++-- cvat/schema.yml | 68 +++++-- dev/format_python_code.sh | 1 + 5 files changed, 314 insertions(+), 110 deletions(-) create mode 100644 cvat/apps/engine/field_validation.py diff --git a/cvat/apps/engine/field_validation.py b/cvat/apps/engine/field_validation.py new file mode 100644 index 000000000000..065a0a0cd422 --- /dev/null +++ b/cvat/apps/engine/field_validation.py @@ -0,0 +1,45 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from typing import Any, Optional, Sequence + +from rest_framework import serializers + + +def drop_null_keys(d: dict[str, Any], *, keys: Optional[Sequence[str]] = None) -> dict[str, Any]: + if keys is None: + keys = d.keys() + return {k: v for k, v in d.items() if k in keys and v is not None} + + +def require_one_of_fields(data: dict[str, Any], keys: Sequence[str]) -> None: + active_count = sum(key in data for key in keys) + if active_count == 1: + return + + options = ", ".join(f'"{k}"' for k in keys) + + if not active_count: + raise serializers.ValidationError(f"One of the fields {options} required") + else: + raise serializers.ValidationError(f"Only 1 of the fields {options} can be used") + + +def require_field(data: dict[str, Any], key: Sequence[str]) -> None: + if key not in data: + raise serializers.ValidationError(f'The "{key}" field is required') + + +def require_one_of_values(data: dict[str, Any], key: str, values: Sequence[Any]) -> None: + if data[key] not in values: + raise serializers.ValidationError( + '"{}" must be one of {}'.format(key, ", ".join(f"{k}" for k in values)) + ) + + +def validate_percent(value: float) -> float: + if not (0 <= value <= 1): + raise serializers.ValidationError("Value must be in the range [0; 1]") + + return value diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 7730eb8b7182..deb319926d22 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -27,7 +27,7 @@ from cvat.apps.dataset_manager.formats.utils import get_label_color from cvat.apps.engine.utils import parse_exception_message -from cvat.apps.engine import models +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 from cvat.apps.engine.permissions import TaskPermission @@ -645,29 +645,69 @@ class JobWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): task_id = serializers.IntegerField() frame_selection_method = serializers.ChoiceField( - choices=models.JobFrameSelectionMethod.choices(), required=False) - - frame_count = serializers.IntegerField(min_value=0, required=False, + choices=models.JobFrameSelectionMethod.choices(), required=False + ) + frames = serializers.ListField( + child=serializers.IntegerField(min_value=0), + required=False, + allow_null=True, + default=None, help_text=textwrap.dedent("""\ - The number of frames included in the job. - Applicable only to the random frame selection - """)) - seed = serializers.IntegerField(min_value=0, required=False, + The list of frame ids. Applicable only to the "{}" frame selection method + """.format(models.JobFrameSelectionMethod.MANUAL)) + ) + frame_count = serializers.IntegerField( + min_value=1, + required=False, + help_text=textwrap.dedent("""\ + The number of frames included in the GT job. + Applicable only to the "{}" frame selection method + """.format(models.JobFrameSelectionMethod.RANDOM_UNIFORM)) + ) + frame_share = serializers.FloatField( + required=False, + allow_null=True, + validators=[field_validation.validate_percent], + help_text=textwrap.dedent("""\ + The share of frames included in the GT job. + Applicable only to the "{}" frame selection method + """.format(models.JobFrameSelectionMethod.RANDOM_UNIFORM)) + ) + frames_per_job_count = serializers.IntegerField( + min_value=1, + required=False, + allow_null=True, + help_text=textwrap.dedent("""\ + The number of frames included in the GT job from each annotation job. + Applicable only to the "{}" frame selection method + """.format(models.JobFrameSelectionMethod.RANDOM_PER_JOB)) + ) + frames_per_job_share = serializers.FloatField( + required=False, + allow_null=True, + validators=[field_validation.validate_percent], + help_text=textwrap.dedent("""\ + The share of frames included in the GT job from each annotation job. + Applicable only to the "{}" frame selection method + """.format(models.JobFrameSelectionMethod.RANDOM_PER_JOB)) + ) + seed = serializers.IntegerField( + min_value=0, + required=False, + allow_null=True, help_text=textwrap.dedent("""\ The seed value for the random number generator. The same value will produce the same frame sets. - Applicable only to the random frame selection. + Applicable only to random frame selection methods. By default, a random value is used. - """)) - - frames = serializers.ListField(child=serializers.IntegerField(min_value=0), - required=False, help_text=textwrap.dedent("""\ - The list of frame ids. Applicable only to the manual frame selection - """)) + """) + ) class Meta: model = models.Job - random_selection_params = ('frame_count', 'seed',) + random_selection_params = ( + 'frame_count', 'frame_share', 'frames_per_job_count', 'frames_per_job_share', 'seed', + ) manual_selection_params = ('frames',) write_once_fields = ('type', 'task_id', 'frame_selection_method',) \ + random_selection_params + manual_selection_params @@ -677,6 +717,30 @@ def to_representation(self, instance): serializer = JobReadSerializer(instance, context=self.context) return serializer.data + def validate(self, attrs): + attrs = field_validation.drop_null_keys(attrs) + + if attrs['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_UNIFORM: + field_validation.require_one_of_fields(attrs, ['frame_count', 'frame_share']) + elif attrs['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_PER_JOB: + field_validation.require_one_of_fields( + attrs, ['frames_per_job_count', 'frames_per_job_share'] + ) + elif attrs['frame_selection_method'] == models.JobFrameSelectionMethod.MANUAL: + field_validation.require_field("frames") + + if ( + attrs['frame_selection_method'] != models.JobFrameSelectionMethod.MANUAL and + attrs.get('frames') + ): + raise serializers.ValidationError( + '"frames" can only be used when "frame_selection_method" is "{}"'.format( + models.JobFrameSelectionMethod.MANUAL + ) + ) + + return super().validate(attrs) + @transaction.atomic def create(self, validated_data): task_id = validated_data.pop('task_id') @@ -695,13 +759,20 @@ def create(self, validated_data): size = task.data.size valid_frame_ids = task.data.get_valid_frame_indices() + # TODO: refactor, test frame_selection_method = validated_data.pop("frame_selection_method", None) if frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: - frame_count = validated_data.pop("frame_count") - if size < frame_count: + if frame_count := validated_data.pop("frame_count", None): + if size < frame_count: + raise serializers.ValidationError( + f"The number of frames requested ({frame_count}) " + f"must be not be greater than the number of the task frames ({size})" + ) + elif frame_share := validated_data.pop("frame_share", None): + frame_count = max(1, int(frame_share * size)) + else: raise serializers.ValidationError( - f"The number of frames requested ({frame_count}) " - f"must be not be greater than the number of the task frames ({size})" + "The number of validation frames is not specified" ) seed = validated_data.pop("seed", None) @@ -720,6 +791,32 @@ def create(self, validated_data): frames = rng.choice( list(valid_frame_ids), size=frame_count, shuffle=False, replace=False ).tolist() + elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_PER_JOB: + if frame_count := validated_data.pop("frames_per_job_count", None): + if size < frame_count: + raise serializers.ValidationError( + f"The number of frames requested ({frame_count}) " + f"must be not be greater than the segment size ({task.segment_size})" + ) + elif frame_share := validated_data.pop("frames_per_job_share", None): + frame_count = max(1, int(frame_share * size)) + else: + raise serializers.ValidationError( + "The number of validation frames is not specified" + ) + + seed = validated_data.pop("seed", None) + + # The RNG backend must not change to yield reproducible results, + # so here we specify it explicitly + from numpy import random + rng = random.Generator(random.MT19937(seed=seed)) + + frames = [] + for segment in task.segment_set.all(): + frames.extend(rng.choice( + list(segment.frame_set), size=frame_count, shuffle=False, replace=False + ).tolist()) elif frame_selection_method == models.JobFrameSelectionMethod.MANUAL: frames = validated_data.pop("frames") @@ -929,84 +1026,103 @@ def __init__(self, *args, **kwargs): kwargs.setdefault('help_text', textwrap.dedent(__class__.__doc__)) super().__init__(*args, **kwargs) -def _validate_percent(value: float) -> float: - if not (0 <= value <= 1): - raise serializers.ValidationError("Value must be in the range [0; 1]") - - return value - class ValidationLayoutParamsSerializer(serializers.Serializer): mode = serializers.ChoiceField(choices=models.ValidationMode.choices(), required=True) frame_selection_method = serializers.ChoiceField( choices=models.JobFrameSelectionMethod.choices(), required=True ) - frames = serializers.ListSerializer( + frames = serializers.ListField( child=serializers.CharField(max_length=MAX_FILENAME_LENGTH), - default=[], required=False, allow_null=True + default=None, + required=False, + allow_null=True, + help_text=textwrap.dedent("""\ + The list of frame ids. Applicable only to the "{}" frame selection method + """.format(models.JobFrameSelectionMethod.MANUAL)) + ) + frame_count = serializers.IntegerField( + min_value=1, + required=False, + help_text=textwrap.dedent("""\ + The number of frames included in the GT job. + Applicable only to the "{}" frame selection method + """.format(models.JobFrameSelectionMethod.RANDOM_UNIFORM)) + ) + frame_share = serializers.FloatField( + required=False, + allow_null=True, + validators=[field_validation.validate_percent], + help_text=textwrap.dedent("""\ + The share of frames included in the GT job. + Applicable only to the "{}" frame selection method + """.format(models.JobFrameSelectionMethod.RANDOM_UNIFORM)) ) - frames_count = serializers.IntegerField(required=False, allow_null=True, min_value=1) - frames_percent = serializers.FloatField( - required=False, allow_null=True, validators=[_validate_percent] + frames_per_job_count = serializers.IntegerField( + min_value=1, + required=False, + allow_null=True, + help_text=textwrap.dedent("""\ + The number of frames included in the GT job from each annotation job. + Applicable only to the "{}" frame selection method + """.format(models.JobFrameSelectionMethod.RANDOM_PER_JOB)) + ) + frames_per_job_share = serializers.FloatField( + required=False, + allow_null=True, + validators=[field_validation.validate_percent], + help_text=textwrap.dedent("""\ + The share of frames included in the GT job from each annotation job. + Applicable only to the "{}" frame selection method + """.format(models.JobFrameSelectionMethod.RANDOM_PER_JOB)) ) - random_seed = serializers.IntegerField(required=False, allow_null=True) - frames_per_job_count = serializers.IntegerField(required=False, allow_null=True, min_value=1) - frames_per_job_percent = serializers.FloatField( - required=False, allow_null=True, validators=[_validate_percent] + random_seed = serializers.IntegerField( + min_value=0, + required=False, + allow_null=True, + help_text=textwrap.dedent("""\ + The seed value for the random number generator. + The same value will produce the same frame sets. + Applicable only to random frame selection methods. + By default, a random value is used. + """) ) def validate(self, attrs): - def drop_none_keys( - d: dict[str, Any], *, keys: Optional[Sequence[str]] = None - ) -> dict[str, Any]: - if keys is None: - keys = d.keys() - return {k: v for k, v in d.items() if k in keys and v is not None} - - def require_one_of_fields(keys: Sequence[str]) -> None: - notset = object() - - active_count = sum(attrs.get(key, notset) is not notset for key in keys) - if active_count == 1: - return - - options = ', '.join(f'"{k}"' for k in keys) - - if not active_count: - raise serializers.ValidationError(f"One of the fields {options} required") - else: - raise serializers.ValidationError(f"Only 1 of the fields {options} can be used") - - def require_one_of_values(key: str, values: Sequence[Any]) -> None: - if attrs[key] not in values: - raise serializers.ValidationError('"{}" must be one of {}'.format( - key, - ', '.join(f"{k}" for k in values) - )) - - attrs = drop_none_keys(attrs) + attrs = field_validation.drop_null_keys(attrs) if attrs["mode"] == models.ValidationMode.GT: - require_one_of_values("frame_selection_method", [ - models.JobFrameSelectionMethod.MANUAL, - models.JobFrameSelectionMethod.RANDOM_UNIFORM, - models.JobFrameSelectionMethod.RANDOM_PER_JOB, - ]) + field_validation.require_one_of_values( + attrs, + "frame_selection_method", + [ + models.JobFrameSelectionMethod.MANUAL, + models.JobFrameSelectionMethod.RANDOM_UNIFORM, + models.JobFrameSelectionMethod.RANDOM_PER_JOB, + ] + ) elif attrs["mode"] == models.ValidationMode.GT_POOL: - require_one_of_values("frame_selection_method", [ - models.JobFrameSelectionMethod.MANUAL, - models.JobFrameSelectionMethod.RANDOM_UNIFORM, - ]) - require_one_of_fields(['frames_per_job_count', 'frames_per_job_percent']) + field_validation.require_one_of_values( + attrs, + "frame_selection_method", + [ + models.JobFrameSelectionMethod.MANUAL, + models.JobFrameSelectionMethod.RANDOM_UNIFORM, + ] + ) + field_validation.require_one_of_fields( + attrs, ['frames_per_job_count', 'frames_per_job_share'] + ) else: assert False, f"Unknown validation mode {attrs['mode']}" if attrs['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_UNIFORM: - require_one_of_fields(['frames_count', 'frames_percent']) + field_validation.require_one_of_fields(attrs, ['frame_count', 'frame_share']) elif attrs['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_PER_JOB: - require_one_of_fields(['frames_per_job_count', 'frames_per_job_percent']) + field_validation.require_one_of_fields( + attrs, ['frames_per_job_count', 'frames_per_job_share'] + ) elif attrs['frame_selection_method'] == models.JobFrameSelectionMethod.MANUAL: - if not attrs.get('frames'): - raise serializers.ValidationError('The "frames" field is required') + field_validation.require_field("frames") if ( attrs['frame_selection_method'] != models.JobFrameSelectionMethod.MANUAL and diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index d0323d053fe7..c2e3bfaf79df 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1145,15 +1145,15 @@ def _update_status(msg: str) -> None: pool_frames: list[int] = [] match validation_params["frame_selection_method"]: case models.JobFrameSelectionMethod.RANDOM_UNIFORM: - frames_count = validation_params["frames_count"] - if len(images) < frames_count: + frame_count = validation_params["frame_count"] + if len(images) < frame_count: raise ValidationError( - f"The number of validation frames requested ({frames_count})" + f"The number of validation frames requested ({frame_count})" f"is greater that the number of task frames ({len(images)})" ) pool_frames = rng.choice( - all_frames, size=frames_count, shuffle=False, replace=False + all_frames, size=frame_count, shuffle=False, replace=False ).tolist() case models.JobFrameSelectionMethod.MANUAL: known_frame_names = {frame.path: frame.frame for frame in images} @@ -1180,9 +1180,9 @@ def _update_status(msg: str) -> None: if validation_params.get("frames_per_job_count"): frames_per_job_count = validation_params["frames_per_job_count"] - elif validation_params.get("frames_per_job_percent"): + elif validation_params.get("frames_per_job_share"): frames_per_job_count = max( - 1, int(validation_params["frames_per_job_percent"] * db_task.segment_size) + 1, int(validation_params["frames_per_job_share"] * db_task.segment_size) ) else: raise ValidationError("The number of validation frames is not specified") @@ -1304,36 +1304,36 @@ def _update_status(msg: str) -> None: case models.JobFrameSelectionMethod.RANDOM_UNIFORM: all_frames = range(len(images)) - if validation_params.get("frames_count"): - frames_count = validation_params["frames_count"] - if len(images) < frames_count: + if validation_params.get("frame_count"): + frame_count = validation_params["frame_count"] + if len(images) < frame_count: raise ValidationError( - f"The number of validation frames requested ({frames_count})" + f"The number of validation frames requested ({frame_count})" f"is greater that the number of task frames ({len(images)})" ) - elif validation_params.get("frames_percent"): - frames_count = max( - 1, int(validation_params["frames_percent"] * len(all_frames)) + elif validation_params.get("frame_share"): + frame_count = max( + 1, int(validation_params["frame_share"] * len(all_frames)) ) else: raise ValidationError("The number of validation frames is not specified") validation_frames = rng.choice( - all_frames, size=frames_count, shuffle=False, replace=False + all_frames, size=frame_count, shuffle=False, replace=False ).tolist() case models.JobFrameSelectionMethod.RANDOM_PER_JOB: if validation_params.get("frames_per_job_count"): - frames_count = validation_params["frames_per_job_count"] - elif validation_params.get("frames_per_job_percent"): - frames_count = max( - 1, int(validation_params["frames_per_job_percent"] * db_task.segment_size) + frame_count = validation_params["frames_per_job_count"] + elif validation_params.get("frames_per_job_share"): + frame_count = max( + 1, int(validation_params["frames_per_job_share"] * db_task.segment_size) ) else: raise ValidationError("The number of validation frames is not specified") for segment in db_task.segment_set.all(): validation_frames.extend(rng.choice( - list(segment.frame_set), size=frames_count, shuffle=False, replace=False + list(segment.frame_set), size=frame_count, shuffle=False, replace=False ).tolist()) case models.JobFrameSelectionMethod.MANUAL: known_frame_names = {frame.path: frame.frame for frame in images} diff --git a/cvat/schema.yml b/cvat/schema.yml index 1102c1f38bb3..7b1fc589b64a 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -8230,25 +8230,48 @@ components: $ref: '#/components/schemas/FrameSelectionMethod' frame_count: type: integer - minimum: 0 + minimum: 1 + description: | + The number of frames included in the GT job. + Applicable only to the "random_uniform" frame selection method + frame_share: + type: number + format: double + nullable: true + description: | + The share of frames included in the GT job. + Applicable only to the "random_uniform" frame selection method + frames_per_job_count: + type: integer + minimum: 1 + nullable: true + description: | + The number of frames included in the GT job from each annotation job. + Applicable only to the "random_per_job" frame selection method + frames_per_job_share: + type: number + format: double + nullable: true description: | - The number of frames included in the job. - Applicable only to the random frame selection + The share of frames included in the GT job from each annotation job. + Applicable only to the "random_per_job" frame selection method seed: type: integer minimum: 0 + nullable: true description: | The seed value for the random number generator. The same value will produce the same frame sets. - Applicable only to the random frame selection. + Applicable only to random frame selection methods. By default, a random value is used. frames: type: array items: type: integer minimum: 0 + nullable: true description: | - The list of frame ids. Applicable only to the manual frame selection + The list of frame ids. Applicable only to the "manual" frame selection method required: - task_id - type @@ -10853,27 +10876,46 @@ components: items: type: string minLength: 1 + maxLength: 1024 nullable: true - default: [] - frames_count: + description: | + The list of frame ids. Applicable only to the "manual" frame selection method + frame_count: type: integer minimum: 1 - nullable: true - frames_percent: + description: | + The number of frames included in the GT job. + Applicable only to the "random_uniform" frame selection method + frame_share: type: number format: double nullable: true - random_seed: - type: integer - nullable: true + description: | + The share of frames included in the GT job. + Applicable only to the "random_uniform" frame selection method frames_per_job_count: type: integer minimum: 1 nullable: true - frames_per_job_percent: + description: | + The number of frames included in the GT job from each annotation job. + Applicable only to the "random_per_job" frame selection method + frames_per_job_share: type: number format: double nullable: true + description: | + The share of frames included in the GT job from each annotation job. + Applicable only to the "random_per_job" frame selection method + random_seed: + type: integer + minimum: 0 + nullable: true + description: | + The seed value for the random number generator. + The same value will produce the same frame sets. + Applicable only to random frame selection methods. + By default, a random value is used. required: - frame_selection_method - mode diff --git a/dev/format_python_code.sh b/dev/format_python_code.sh index 7eff923abb8a..d13fede1dcd7 100755 --- a/dev/format_python_code.sh +++ b/dev/format_python_code.sh @@ -28,6 +28,7 @@ for paths in \ "cvat/apps/engine/frame_provider.py" \ "cvat/apps/engine/cache.py" \ "cvat/apps/engine/default_settings.py" \ + "cvat/apps/engine/field_validation.py" \ ; do ${BLACK} -- ${paths} ${ISORT} -- ${paths} From bb48ae6b463c045bd52509d81341ba7233978d00 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 14:43:36 +0300 Subject: [PATCH 102/227] Refactor serializers --- cvat/apps/engine/models.py | 5 +- cvat/apps/engine/serializers.py | 232 ++++++++++++++++++-------------- cvat/apps/engine/views.py | 4 +- 3 files changed, 136 insertions(+), 105 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index c7792dd32a23..0a098c5e3dd5 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -232,8 +232,11 @@ def __str__(self): return self.value class ValidationLayout(models.Model): + # TODO: find a way to avoid using the same mode for storing request parameters + # before data uploading and after + task_data = models.OneToOneField( - 'Data', on_delete=models.CASCADE, related_name="validation_layout" + 'Data', on_delete=models.CASCADE, related_name="validation_layout_params" ) mode = models.CharField(max_length=32, choices=ValidationMode.choices()) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index deb319926d22..eca3ee61c16d 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -633,14 +633,14 @@ def to_representation(self, instance): return data - class JobWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): assignee = serializers.IntegerField(allow_null=True, required=False) - # NOTE: Field variations can be expressed using serializer inheritance, but it is + # NOTE: Field sets can be expressed using serializer inheritance, but it is # harder to use then: we need to make a manual switch in get_serializer_class() # and create an extra serializer type in the API schema. - # Need to investigate how it can be simplified. + # Need to investigate how it can be simplified. It can also be done just internally, + # (e.g. just on the validation side), but it will complicate the implementation. type = serializers.ChoiceField(choices=models.JobType.choices()) task_id = serializers.IntegerField() @@ -691,7 +691,7 @@ class JobWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): Applicable only to the "{}" frame selection method """.format(models.JobFrameSelectionMethod.RANDOM_PER_JOB)) ) - seed = serializers.IntegerField( + random_seed = serializers.IntegerField( min_value=0, required=False, allow_null=True, @@ -706,7 +706,8 @@ class JobWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): class Meta: model = models.Job random_selection_params = ( - 'frame_count', 'frame_share', 'frames_per_job_count', 'frames_per_job_share', 'seed', + 'frame_count', 'frame_share', 'frames_per_job_count', 'frames_per_job_share', + 'random_seed', ) manual_selection_params = ('frames',) write_once_fields = ('type', 'task_id', 'frame_selection_method',) \ @@ -743,110 +744,110 @@ def validate(self, attrs): @transaction.atomic def create(self, validated_data): + if validated_data["type"] != models.JobType.GROUND_TRUTH: + raise serializers.ValidationError(f"Unexpected job type '{validated_data['type']}'") + task_id = validated_data.pop('task_id') task = models.Task.objects.select_for_update().get(pk=task_id) - if validated_data["type"] == models.JobType.GROUND_TRUTH: - if not task.data: - raise serializers.ValidationError( - "This task has no data attached yet. Please set up task data and try again" - ) - if task.dimension != models.DimensionType.DIM_2D: - raise serializers.ValidationError( - "Ground Truth jobs can only be added in 2d tasks" - ) + if not task.data: + raise serializers.ValidationError( + "This task has no data attached yet. Please set up task data and try again" + ) + if task.dimension != models.DimensionType.DIM_2D: + raise serializers.ValidationError( + "Ground Truth jobs can only be added in 2d tasks" + ) - size = task.data.size - valid_frame_ids = task.data.get_valid_frame_indices() - - # TODO: refactor, test - frame_selection_method = validated_data.pop("frame_selection_method", None) - if frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: - if frame_count := validated_data.pop("frame_count", None): - if size < frame_count: - raise serializers.ValidationError( - f"The number of frames requested ({frame_count}) " - f"must be not be greater than the number of the task frames ({size})" - ) - elif frame_share := validated_data.pop("frame_share", None): - frame_count = max(1, int(frame_share * size)) - else: + size = task.data.size + valid_frame_ids = task.data.get_valid_frame_indices() + + # TODO: refactor, test + frame_selection_method = validated_data.pop("frame_selection_method") + if frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: + if frame_count := validated_data.pop("frame_count", None): + if size < frame_count: raise serializers.ValidationError( - "The number of validation frames is not specified" + f"The number of frames requested ({frame_count}) " + f"must be not be greater than the number of the task frames ({size})" ) + elif frame_share := validated_data.pop("frame_share", None): + frame_count = max(1, int(frame_share * size)) + else: + raise serializers.ValidationError( + "The number of validation frames is not specified" + ) - seed = validated_data.pop("seed", None) - - # The RNG backend must not change to yield reproducible results, - # so here we specify it explicitly - from numpy import random - rng = random.Generator(random.MT19937(seed=seed)) - - if seed is not None and frame_count < size: - # Reproduce the old (a little bit incorrect) behavior that existed before - # https://github.com/cvat-ai/cvat/pull/7126 - # to make the old seed-based sequences reproducible - valid_frame_ids = [v for v in valid_frame_ids if v != task.data.stop_frame] - - frames = rng.choice( - list(valid_frame_ids), size=frame_count, shuffle=False, replace=False - ).tolist() - elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_PER_JOB: - if frame_count := validated_data.pop("frames_per_job_count", None): - if size < frame_count: - raise serializers.ValidationError( - f"The number of frames requested ({frame_count}) " - f"must be not be greater than the segment size ({task.segment_size})" - ) - elif frame_share := validated_data.pop("frames_per_job_share", None): - frame_count = max(1, int(frame_share * size)) - else: + seed = validated_data.pop("random_seed", None) + + # The RNG backend must not change to yield reproducible results, + # so here we specify it explicitly + from numpy import random + rng = random.Generator(random.MT19937(seed=seed)) + + if seed is not None and frame_count < size: + # Reproduce the old (a little bit incorrect) behavior that existed before + # https://github.com/cvat-ai/cvat/pull/7126 + # to make the old seed-based sequences reproducible + valid_frame_ids = [v for v in valid_frame_ids if v != task.data.stop_frame] + + frames = rng.choice( + list(valid_frame_ids), size=frame_count, shuffle=False, replace=False + ).tolist() + elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_PER_JOB: + if frame_count := validated_data.pop("frames_per_job_count", None): + if size < frame_count: raise serializers.ValidationError( - "The number of validation frames is not specified" + f"The number of frames requested ({frame_count}) " + f"must be not be greater than the segment size ({task.segment_size})" ) + elif frame_share := validated_data.pop("frames_per_job_share", None): + frame_count = max(1, int(frame_share * size)) + else: + raise serializers.ValidationError( + "The number of validation frames is not specified" + ) - seed = validated_data.pop("seed", None) + seed = validated_data.pop("random_seed", None) - # The RNG backend must not change to yield reproducible results, - # so here we specify it explicitly - from numpy import random - rng = random.Generator(random.MT19937(seed=seed)) + # The RNG backend must not change to yield reproducible results, + # so here we specify it explicitly + from numpy import random + rng = random.Generator(random.MT19937(seed=seed)) - frames = [] - for segment in task.segment_set.all(): - frames.extend(rng.choice( - list(segment.frame_set), size=frame_count, shuffle=False, replace=False - ).tolist()) - elif frame_selection_method == models.JobFrameSelectionMethod.MANUAL: - frames = validated_data.pop("frames") + frames = [] + for segment in task.segment_set.all(): + frames.extend(rng.choice( + list(segment.frame_set), size=frame_count, shuffle=False, replace=False + ).tolist()) + elif frame_selection_method == models.JobFrameSelectionMethod.MANUAL: + frames = validated_data.pop("frames") - if not frames: - raise serializers.ValidationError("The list of frames cannot be empty") + if not frames: + raise serializers.ValidationError("The list of frames cannot be empty") - unique_frames = set(frames) - if len(unique_frames) != len(frames): - raise serializers.ValidationError(f"Frames must not repeat") + unique_frames = set(frames) + if len(unique_frames) != len(frames): + raise serializers.ValidationError(f"Frames must not repeat") - invalid_ids = unique_frames.difference(valid_frame_ids) - if invalid_ids: - raise serializers.ValidationError( - "The following frames are not included " - f"in the task: {','.join(map(str, invalid_ids))}" - ) - else: + invalid_ids = unique_frames.difference(valid_frame_ids) + if invalid_ids: raise serializers.ValidationError( - f"Unexpected frame selection method '{frame_selection_method}'" + "The following frames are not included " + f"in the task: {','.join(map(str, invalid_ids))}" ) - - segment = models.Segment.objects.create( - start_frame=0, - stop_frame=task.data.size - 1, - frames=frames, - task=task, - type=models.SegmentType.SPECIFIC_FRAMES, - ) else: - raise serializers.ValidationError(f"Unexpected job type '{validated_data['type']}'") + raise serializers.ValidationError( + f"Unexpected frame selection method '{frame_selection_method}'" + ) + + segment = models.Segment.objects.create( + start_frame=0, + stop_frame=task.data.size - 1, + frames=frames, + task=task, + type=models.SegmentType.SPECIFIC_FRAMES, + ) validated_data['segment'] = segment validated_data["assignee_id"] = validated_data.pop("assignee", None) @@ -861,6 +862,29 @@ def create(self, validated_data): job.save(update_fields=["assignee_updated_date"]) job.make_dirs() + + # Update validation layout in the task + validation_layout_params = { + "mode": models.ValidationMode.GT, + "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, + + # reset other fields + "random_seed": None, + "frame_count": None, + "frame_share": None, + "frames_per_job_count": None, + "frames_per_job_share": None, + } + validation_layout_serializer = ValidationLayoutParamsSerializer() + if not hasattr(task.data, 'validation_layout'): + validation_layout = validation_layout_serializer.create(validation_layout_params) + validation_layout.task_data_id = task.data_id + validation_layout.save(update_fields=["task_data_id"]) + else: + validation_layout_serializer.update( + task.data.validation_layout, validation_layout_params + ) + return job def update(self, instance, validated_data): @@ -1026,7 +1050,7 @@ def __init__(self, *args, **kwargs): kwargs.setdefault('help_text', textwrap.dedent(__class__.__doc__)) super().__init__(*args, **kwargs) -class ValidationLayoutParamsSerializer(serializers.Serializer): +class ValidationLayoutParamsSerializer(serializers.ModelSerializer): mode = serializers.ChoiceField(choices=models.ValidationMode.choices(), required=True) frame_selection_method = serializers.ChoiceField( choices=models.JobFrameSelectionMethod.choices(), required=True @@ -1087,6 +1111,13 @@ class ValidationLayoutParamsSerializer(serializers.Serializer): """) ) + class Meta: + fields = ( + 'mode', 'frame_selection_method', 'random_seed', + 'frame_count', 'frame_share', 'frames_per_job_count', 'frames_per_job_share', + ) + model = models.ValidationLayout + def validate(self, attrs): attrs = field_validation.drop_null_keys(attrs) @@ -1137,11 +1168,10 @@ def validate(self, attrs): return super().validate(attrs) @transaction.atomic - def create(self, validated_data): + def create(self, validated_data: dict[str, Any]) -> models.ValidationLayout: frames = validated_data.pop('frames', None) - instance = models.ValidationLayout(**validated_data) - instance.save() + instance = super().create(**validated_data) if frames: models.ValidationFrame.objects.bulk_create( @@ -1152,17 +1182,15 @@ def create(self, validated_data): return instance @transaction.atomic - def update(self, instance, validated_data): + def update( + self, instance: models.ValidationLayout, validated_data: dict[str, Any] + ) -> models.ValidationLayout: frames = validated_data.pop('frames', None) - for k, v in validated_data.items(): - setattr(instance, k, v) - instance.save() + instance = super().update(instance, validated_data) if frames: - if instance.frames.count(): - for db_frame in instance.frames.all(): - db_frame.delete() + models.ValidationFrame.objects.filter(validation_layout=instance).delete() models.ValidationFrame.objects.bulk_create( models.ValidationFrame(validation_layout=instance, path=frame) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 56fa3819641f..25fa3b521535 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -2129,7 +2129,7 @@ def honeypot(self, request, pk): Prefetch('segment__task__data', queryset=( models.Data.objects - .select_related('video') + .select_related('video', 'validation_layout') .prefetch_related( Prefetch('images', queryset=models.Image.objects.order_by('frame')) ) @@ -2138,7 +2138,7 @@ def honeypot(self, request, pk): ).get(pk=pk) if ( - not db_job.segment.task.data.validation_layout or + not hasattr(db_job.segment.task.data, 'validation_layout') or db_job.segment.task.data.validation_layout.mode != models.ValidationMode.GT_POOL ): raise ValidationError("Honeypots are not configured in the task") From 81e1692900a84fc48eca61480d95478731a2d0d8 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 17:18:03 +0300 Subject: [PATCH 103/227] Add more validations, rename model fields after api changes --- ...laceholder_image_real_frame_id_and_more.py | 8 +- cvat/apps/engine/models.py | 8 +- cvat/apps/engine/serializers.py | 13 +++- cvat/apps/engine/task.py | 74 ++++++++++--------- cvat/apps/engine/views.py | 9 +++ 5 files changed, 69 insertions(+), 43 deletions(-) diff --git a/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py b/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py index 1d71001ea735..d6692b476377 100644 --- a/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py +++ b/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-08-26 15:46 +# Generated by Django 4.2.15 on 2024-08-28 13:50 from django.db import migrations, models import django.db.models.deletion @@ -46,10 +46,10 @@ class Migration(migrations.Migration): ), ), ("random_seed", models.IntegerField(null=True)), - ("frames_count", models.IntegerField(null=True)), - ("frames_percent", models.FloatField(null=True)), + ("frame_count", models.IntegerField(null=True)), + ("frame_share", models.FloatField(null=True)), ("frames_per_job_count", models.IntegerField(null=True)), - ("frames_per_job_percent", models.FloatField(null=True)), + ("frames_per_job_share", models.FloatField(null=True)), ( "task_data", models.OneToOneField( diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 0a098c5e3dd5..e7cdab2940a2 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -236,7 +236,7 @@ class ValidationLayout(models.Model): # before data uploading and after task_data = models.OneToOneField( - 'Data', on_delete=models.CASCADE, related_name="validation_layout_params" + 'Data', on_delete=models.CASCADE, related_name="validation_layout" ) mode = models.CharField(max_length=32, choices=ValidationMode.choices()) @@ -247,10 +247,10 @@ class ValidationLayout(models.Model): random_seed = models.IntegerField(null=True) frames: models.manager.RelatedManager[ValidationFrame] - frames_count = models.IntegerField(null=True) - frames_percent = models.FloatField(null=True) + frame_count = models.IntegerField(null=True) + frame_share = models.FloatField(null=True) frames_per_job_count = models.IntegerField(null=True) - frames_per_job_percent = models.FloatField(null=True) + frames_per_job_share = models.FloatField(null=True) class ValidationFrame(models.Model): validation_layout = models.ForeignKey( diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index eca3ee61c16d..40557731adc7 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -759,6 +759,15 @@ def create(self, validated_data): "Ground Truth jobs can only be added in 2d tasks" ) + if ( + hasattr(task.data, 'validation_layout') and + task.data.validation_layout.mode == models.ValidationMode.GT_POOL + ): + raise serializers.ValidationError( + f'Task with validation mode "{models.ValidationMode.GT_POOL}" ' + 'cannot have more than 1 GT job' + ) + size = task.data.size valid_frame_ids = task.data.get_valid_frame_indices() @@ -1113,7 +1122,7 @@ class ValidationLayoutParamsSerializer(serializers.ModelSerializer): class Meta: fields = ( - 'mode', 'frame_selection_method', 'random_seed', + 'mode', 'frame_selection_method', 'random_seed', 'frames', 'frame_count', 'frame_share', 'frames_per_job_count', 'frames_per_job_share', ) model = models.ValidationLayout @@ -1171,7 +1180,7 @@ def validate(self, attrs): def create(self, validated_data: dict[str, Any]) -> models.ValidationLayout: frames = validated_data.pop('frames', None) - instance = super().create(**validated_data) + instance = super().create(validated_data) if frames: models.ValidationFrame.objects.bulk_create( diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index c2e3bfaf79df..79817b87ccbd 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -348,13 +348,12 @@ def _validate_validation_params( return None if ( - params['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_PER_JOB or - params['mode'] == models.ValidationMode.GT_POOL - ) and (frames_per_job := params.get('frames_per_job_count')): - if db_task.segment_size <= frames_per_job: - raise ValidationError( - "Validation frame count per job cannot be greater than segment size" - ) + params['mode'] == models.ValidationMode.GT and + params['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_PER_JOB and + (frames_per_job := params.get('frames_per_job_count')) and + db_task.segment_size <= frames_per_job + ): + raise ValidationError("Validation frame count per job cannot be greater than segment size") if params['mode'] != models.ValidationMode.GT_POOL: return params @@ -1145,12 +1144,16 @@ def _update_status(msg: str) -> None: pool_frames: list[int] = [] match validation_params["frame_selection_method"]: case models.JobFrameSelectionMethod.RANDOM_UNIFORM: - frame_count = validation_params["frame_count"] - if len(images) < frame_count: - raise ValidationError( - f"The number of validation frames requested ({frame_count})" - f"is greater that the number of task frames ({len(images)})" - ) + if frame_count := validation_params.get("frame_count"): + if len(images) <= frame_count: + raise ValidationError( + f"The number of validation frames requested ({frame_count})" + f"must be less than the number of task frames ({len(images)})" + ) + elif frame_share := validation_params.get("frame_share"): + frame_count = max(1, len(images) * frame_share) + else: + raise ValidationError("The number of validation frames is not specified") pool_frames = rng.choice( all_frames, size=frame_count, shuffle=False, replace=False @@ -1178,22 +1181,28 @@ def _update_status(msg: str) -> None: # 2. distribute pool frames from datumaro.util import take_by - if validation_params.get("frames_per_job_count"): - frames_per_job_count = validation_params["frames_per_job_count"] - elif validation_params.get("frames_per_job_share"): - frames_per_job_count = max( - 1, int(validation_params["frames_per_job_share"] * db_task.segment_size) - ) + if frames_per_job_count := validation_params.get("frames_per_job_count"): + if len(pool_frames) < frames_per_job_count and validation_params.get("frame_count"): + raise ValidationError( + f"The requested number of validation frames per job ({frames_per_job_count})" + f"is greater than the validation pool size ({len(pool_frames)})" + ) + elif frames_per_job_share := validation_params.get("frames_per_job_share"): + frames_per_job_count = max(1, int(frames_per_job_share * db_task.segment_size)) else: raise ValidationError("The number of validation frames is not specified") + frames_per_job_count = min(len(pool_frames), frames_per_job_count) + # Allocate frames for jobs job_file_mapping: JobFileMapping = [] new_db_images: list[models.Image] = [] validation_frames: list[int] = [] frame_idx_map: dict[int, int] = {} # new to original id for job_frames in take_by(non_pool_frames, count=db_task.segment_size or db_data.size): - job_validation_frames = rng.choice(pool_frames, size=frames_per_job_count, replace=False) + job_validation_frames = rng.choice( + pool_frames, size=frames_per_job_count, replace=False + ) job_frames += job_validation_frames.tolist() random.shuffle(job_frames) # don't use the same rng @@ -1240,7 +1249,7 @@ def _update_status(msg: str) -> None: for validation_frame in validation_frames: image = new_db_images[validation_frame] assert image.is_placeholder - image.real_frame_id = frame_id_map[image.real_frame_id] # TODO: maybe not needed + image.real_frame_id = frame_id_map[image.real_frame_id] images = new_db_images db_data.size = len(images) @@ -1304,17 +1313,14 @@ def _update_status(msg: str) -> None: case models.JobFrameSelectionMethod.RANDOM_UNIFORM: all_frames = range(len(images)) - if validation_params.get("frame_count"): - frame_count = validation_params["frame_count"] + if frame_count := validation_params.get("frame_count"): if len(images) < frame_count: raise ValidationError( f"The number of validation frames requested ({frame_count})" f"is greater that the number of task frames ({len(images)})" ) - elif validation_params.get("frame_share"): - frame_count = max( - 1, int(validation_params["frame_share"] * len(all_frames)) - ) + elif frame_share := validation_params.get("frame_share"): + frame_count = max(1, int(frame_share * len(all_frames))) else: raise ValidationError("The number of validation frames is not specified") @@ -1322,12 +1328,14 @@ def _update_status(msg: str) -> None: all_frames, size=frame_count, shuffle=False, replace=False ).tolist() case models.JobFrameSelectionMethod.RANDOM_PER_JOB: - if validation_params.get("frames_per_job_count"): - frame_count = validation_params["frames_per_job_count"] - elif validation_params.get("frames_per_job_share"): - frame_count = max( - 1, int(validation_params["frames_per_job_share"] * db_task.segment_size) - ) + if frame_count := validation_params.get("frames_per_job_count"): + if db_task.segment_size < frame_count: + raise ValidationError( + "The requested number of GT frames per job must be less " + f"than task segment size ({db_task.segment_size})" + ) + elif frame_share := validation_params.get("frames_per_job_share"): + frame_count = max(1, int(frame_share * db_task.segment_size)) else: raise ValidationError("The number of validation frames is not specified") diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 25fa3b521535..b0e7657fee4d 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -1716,6 +1716,15 @@ def perform_destroy(self, instance): if instance.type != JobType.GROUND_TRUTH: raise ValidationError("Only ground truth jobs can be removed") + if hasattr(instance.segment.task.data, 'validation_layout') and ( + instance.segment.task.data.validation_layout.mode == models.ValidationMode.GT_POOL + ): + raise ValidationError( + 'GT jobs cannot be removed when task validation mode is "{}"'.format( + models.ValidationMode.GT_POOL + ) + ) + return super().perform_destroy(instance) # UploadMixin method From f84d93f0479f1afbdee2d923b4943fdf86c3fece Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 17:20:16 +0300 Subject: [PATCH 104/227] Update api schema --- cvat/schema.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cvat/schema.yml b/cvat/schema.yml index 7b1fc589b64a..03ca0302c647 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -8255,7 +8255,7 @@ components: description: | The share of frames included in the GT job from each annotation job. Applicable only to the "random_per_job" frame selection method - seed: + random_seed: type: integer minimum: 0 nullable: true @@ -10871,6 +10871,15 @@ components: $ref: '#/components/schemas/ValidationMode' frame_selection_method: $ref: '#/components/schemas/FrameSelectionMethod' + random_seed: + type: integer + minimum: 0 + nullable: true + description: | + The seed value for the random number generator. + The same value will produce the same frame sets. + Applicable only to random frame selection methods. + By default, a random value is used. frames: type: array items: @@ -10907,15 +10916,6 @@ components: description: | The share of frames included in the GT job from each annotation job. Applicable only to the "random_per_job" frame selection method - random_seed: - type: integer - minimum: 0 - nullable: true - description: | - The seed value for the random number generator. - The same value will produce the same frame sets. - Applicable only to random frame selection methods. - By default, a random value is used. required: - frame_selection_method - mode From be7ebb37caa5be912734aab3a303a49fb886902c Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 17:29:27 +0300 Subject: [PATCH 105/227] Improve validation messages, fix error --- cvat/apps/engine/task.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 79817b87ccbd..9f8d6d34680f 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1130,7 +1130,10 @@ def _update_status(msg: str) -> None: # Prepare jobs if validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: if db_task.mode != 'annotation': - raise ValidationError("gt pool can only be used with 'annotation' mode tasks") + raise ValidationError( + f"validation mode '{models.ValidationMode.GT_POOL}' can only be used " + "with 'annotation' mode tasks" + ) # 1. select pool frames all_frames = range(len(images)) @@ -1147,11 +1150,11 @@ def _update_status(msg: str) -> None: if frame_count := validation_params.get("frame_count"): if len(images) <= frame_count: raise ValidationError( - f"The number of validation frames requested ({frame_count})" + f"The number of validation frames requested ({frame_count}) " f"must be less than the number of task frames ({len(images)})" ) elif frame_share := validation_params.get("frame_share"): - frame_count = max(1, len(images) * frame_share) + frame_count = max(1, int(len(images) * frame_share)) else: raise ValidationError("The number of validation frames is not specified") @@ -1184,7 +1187,7 @@ def _update_status(msg: str) -> None: if frames_per_job_count := validation_params.get("frames_per_job_count"): if len(pool_frames) < frames_per_job_count and validation_params.get("frame_count"): raise ValidationError( - f"The requested number of validation frames per job ({frames_per_job_count})" + f"The requested number of validation frames per job ({frames_per_job_count}) " f"is greater than the validation pool size ({len(pool_frames)})" ) elif frames_per_job_share := validation_params.get("frames_per_job_share"): @@ -1316,7 +1319,7 @@ def _update_status(msg: str) -> None: if frame_count := validation_params.get("frame_count"): if len(images) < frame_count: raise ValidationError( - f"The number of validation frames requested ({frame_count})" + f"The number of validation frames requested ({frame_count}) " f"is greater that the number of task frames ({len(images)})" ) elif frame_share := validation_params.get("frame_share"): From e4db8ad8af0b6432664557e18eee9f68314a8fb9 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 17:59:02 +0300 Subject: [PATCH 106/227] Reuse _get --- cvat/apps/engine/cache.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 4995c0466640..2b4fa5de63d4 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -77,14 +77,7 @@ def create_item() -> _CacheItem: return item - slogger.glob.info(f"Starting to get chunk from cache: key {key}") - try: - 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)}") - + item = self._get(key) if not item: item = create_item() else: From b1c54f951856aba3bb624983cef9dd20c3af3f5e Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 17:59:31 +0300 Subject: [PATCH 107/227] Make get_checksum private --- cvat/apps/engine/cache.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 2b4fa5de63d4..b856eebcda31 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -58,7 +58,7 @@ class MediaCache: def __init__(self) -> None: self._cache = caches["media"] - def get_checksum(self, value: bytes) -> int: + def _get_checksum(self, value: bytes) -> int: return zlib.crc32(value) def _get_or_set_cache_item( @@ -70,7 +70,7 @@ def create_item() -> _CacheItem: slogger.glob.info(f"Ending to prepare chunk: key {key}") if item_data[0]: - item = (item_data[0], item_data[1], self.get_checksum(item_data[0].getbuffer())) + item = (item_data[0], item_data[1], self._get_checksum(item_data[0].getbuffer())) self._cache.set(key, item) else: item = (item_data[0], item_data[1], None) @@ -84,7 +84,7 @@ def create_item() -> _CacheItem: # 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): + if item_checksum != self._get_checksum(item_data): slogger.glob.info(f"Recreating cache item {key} due to checksum mismatch") item = create_item() From 5312b0005a197f5858cc035eb35fd64448462d9c Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 18:00:06 +0300 Subject: [PATCH 108/227] Add get_raw_data_dirname to the Data model --- cvat/apps/engine/cache.py | 17 ++++------------- cvat/apps/engine/models.py | 7 +++++++ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index b856eebcda31..62301b6253cb 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -170,11 +170,9 @@ def _read_raw_images( db_task: models.Task, frame_ids: Sequence[int], *, - raw_data_dir: str, manifest_path: str, ): db_data = db_task.data - dimension = db_task.dimension if os.path.isfile(manifest_path) and db_data.storage == models.StorageChoice.CLOUD_STORAGE: reader = ImageReaderWithManifest(manifest_path) @@ -235,6 +233,7 @@ def _read_raw_images( .all() ) + raw_data_dir = db_data.get_raw_data_dirname() media = [] for frame_id, frame_path in db_images: if frame_id == next_requested_frame_id: @@ -248,7 +247,7 @@ def _read_raw_images( assert next_requested_frame_id is None - if dimension == models.DimensionType.DIM_2D: + if db_task.dimension == models.DimensionType.DIM_2D: media = preload_images(media) yield from media @@ -263,16 +262,10 @@ def _read_raw_frames( db_data = db_task.data - raw_data_dir = { - models.StorageChoice.LOCAL: db_data.get_upload_dirname(), - models.StorageChoice.SHARE: settings.SHARE_ROOT, - models.StorageChoice.CLOUD_STORAGE: db_data.get_upload_dirname(), - }[db_data.storage] - manifest_path = db_data.get_manifest_path() if hasattr(db_data, "video"): - source_path = os.path.join(raw_data_dir, db_data.video.path) + source_path = os.path.join(db_data.get_raw_data_dirname(), db_data.video.path) reader = VideoReaderWithManifest( manifest_path=manifest_path, @@ -298,9 +291,7 @@ 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, raw_data_dir=raw_data_dir, manifest_path=manifest_path - ) + yield from self._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 diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index b1f01ab64a76..c88c9360bdfc 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -252,6 +252,13 @@ def get_data_dirname(self): def get_upload_dirname(self): return os.path.join(self.get_data_dirname(), "raw") + def get_raw_data_dirname(self) -> str: + return { + StorageChoice.LOCAL: self.get_upload_dirname(), + StorageChoice.SHARE: settings.SHARE_ROOT, + StorageChoice.CLOUD_STORAGE: self.get_upload_dirname(), + }[self.storage] + def get_compressed_cache_dirname(self): return os.path.join(self.get_data_dirname(), "compressed") From 3c117fe1fa7153eebb85691727a29e37ab62a0aa Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 18:00:40 +0300 Subject: [PATCH 109/227] Make SegmentFrameProvider available in make_frame_provider --- cvat/apps/engine/cache.py | 9 ++++----- cvat/apps/engine/frame_provider.py | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 62301b6253cb..e5b09c72c256 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -391,14 +391,13 @@ def _prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: os.path.join(os.path.dirname(__file__), "assets/3d_preview.jpeg") ) else: - from cvat.apps.engine.frame_provider import ( + from cvat.apps.engine.frame_provider import ( # avoid circular import FrameOutputType, - SegmentFrameProvider, - TaskFrameProvider, + make_frame_provider, ) - task_frame_provider = TaskFrameProvider(db_segment.task) - segment_frame_provider = SegmentFrameProvider(db_segment) + task_frame_provider = make_frame_provider(db_segment.task) + segment_frame_provider = make_frame_provider(db_segment) preview = segment_frame_provider.get_frame( task_frame_provider.get_rel_frame_number(min(db_segment.frame_set)), quality=FrameQuality.COMPRESSED, diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 11ea5539e29d..0821271fd1ab 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from enum import Enum, auto from io import BytesIO -from typing import Any, Callable, Generic, Iterator, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Callable, Generic, Iterator, Optional, Tuple, Type, TypeVar, Union, overload import av import cv2 @@ -561,9 +561,25 @@ def __init__(self, db_job: models.Job) -> None: super().__init__(db_job.segment) -def make_frame_provider(data_source: Union[models.Job, models.Task, Any]) -> IFrameProvider: +@overload +def make_frame_provider(data_source: models.Job) -> JobFrameProvider: ... + + +@overload +def make_frame_provider(data_source: models.Segment) -> SegmentFrameProvider: ... + + +@overload +def make_frame_provider(data_source: models.Task) -> TaskFrameProvider: ... + + +def make_frame_provider( + data_source: Union[models.Job, models.Segment, models.Task, Any] +) -> IFrameProvider: if isinstance(data_source, models.Task): frame_provider = TaskFrameProvider(data_source) + elif isinstance(data_source, models.Segment): + frame_provider = SegmentFrameProvider(data_source) elif isinstance(data_source, models.Job): frame_provider = JobFrameProvider(data_source) else: From 98eff81384549a56b37f893b6ab1f916dde08a79 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 18:00:58 +0300 Subject: [PATCH 110/227] Remove extra variable --- cvat/apps/engine/task.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 96c78d9bbc6a..f24cd686a587 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -923,11 +923,9 @@ def _update_status(msg: str) -> None: db_data.compressed_chunk_type = models.DataChoice.VIDEO if task_mode == 'interpolation' and not data['use_zip_chunks'] else models.DataChoice.IMAGESET db_data.original_chunk_type = models.DataChoice.VIDEO if task_mode == 'interpolation' else models.DataChoice.IMAGESET - compressed_chunk_writer_class = Mpeg4CompressedChunkWriter if db_data.compressed_chunk_type == models.DataChoice.VIDEO else ZipCompressedChunkWriter - # calculate chunk size if it isn't specified if db_data.chunk_size is None: - if issubclass(compressed_chunk_writer_class, ZipCompressedChunkWriter): + if db_data.compressed_chunk_type == models.DataChoice.IMAGESET: first_image_idx = db_data.start_frame if not is_data_in_cloud: w, h = extractor.get_image_size(first_image_idx) From 316ec785c933f966213c9f800b93f06f0a9ab1ce Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 18:41:51 +0300 Subject: [PATCH 111/227] Include both cases of CVAT_ALLOW_STATIC_CACHE in CI checks --- .github/workflows/full.yml | 2 ++ .github/workflows/main.yml | 3 ++- .github/workflows/schedule.yml | 6 ++++++ .../docker-compose.configurable_static_cache.yml | 16 ++++++++++++++++ tests/python/rest_api/test_jobs.py | 2 +- tests/python/rest_api/test_queues.py | 2 +- .../rest_api/test_resource_import_export.py | 2 +- tests/python/rest_api/test_tasks.py | 14 ++++++++++---- tests/python/shared/fixtures/init.py | 3 ++- 9 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 tests/docker-compose.configurable_static_cache.yml diff --git a/.github/workflows/full.yml b/.github/workflows/full.yml index 4042828ccfb5..f6e0d56a83a5 100644 --- a/.github/workflows/full.yml +++ b/.github/workflows/full.yml @@ -172,6 +172,8 @@ jobs: id: run_tests run: | pytest tests/python/ + ONE_RUNNING_JOB_IN_QUEUE_PER_USER="true" pytest tests/python/rest_api/test_queues.py + CVAT_ALLOW_STATIC_CACHE="true" pytest -k "TestTaskData" tests/python - name: Creating a log file from cvat containers if: failure() && steps.run_tests.conclusion == 'failure' diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 822934ee9ba4..3ab279d13713 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -184,8 +184,9 @@ jobs: COVERAGE_PROCESS_START: ".coveragerc" run: | pytest tests/python/ --cov --cov-report=json - for COVERAGE_FILE in `find -name "coverage*.json" -type f -printf "%f\n"`; do mv ${COVERAGE_FILE} "${COVERAGE_FILE%%.*}_0.json"; done ONE_RUNNING_JOB_IN_QUEUE_PER_USER="true" pytest tests/python/rest_api/test_queues.py --cov --cov-report=json + CVAT_ALLOW_STATIC_CACHE="true" pytest -k "TestTaskData" tests/python --cov --cov-report=json + for COVERAGE_FILE in `find -name "coverage*.json" -type f -printf "%f\n"`; do mv ${COVERAGE_FILE} "${COVERAGE_FILE%%.*}_0.json"; done - name: Uploading code coverage results as an artifact uses: actions/upload-artifact@v3.1.1 diff --git a/.github/workflows/schedule.yml b/.github/workflows/schedule.yml index 7fd9e6f63045..faf1e8a15eb6 100644 --- a/.github/workflows/schedule.yml +++ b/.github/workflows/schedule.yml @@ -170,6 +170,12 @@ jobs: pytest tests/python/ pytest tests/python/ --stop-services + ONE_RUNNING_JOB_IN_QUEUE_PER_USER="true" pytest tests/python/rest_api/test_queues.py + pytest tests/python/ --stop-services + + CVAT_ALLOW_STATIC_CACHE="true" pytest tests/python + pytest tests/python/ --stop-services + - name: Unit tests env: HOST_COVERAGE_DATA_DIR: ${{ github.workspace }} diff --git a/tests/docker-compose.configurable_static_cache.yml b/tests/docker-compose.configurable_static_cache.yml new file mode 100644 index 000000000000..5afa43470803 --- /dev/null +++ b/tests/docker-compose.configurable_static_cache.yml @@ -0,0 +1,16 @@ +services: + cvat_server: + environment: + CVAT_ALLOW_STATIC_CACHE: '${CVAT_ALLOW_STATIC_CACHE:-no}' + + cvat_worker_import: + environment: + CVAT_ALLOW_STATIC_CACHE: '${CVAT_ALLOW_STATIC_CACHE:-no}' + + cvat_worker_export: + environment: + CVAT_ALLOW_STATIC_CACHE: '${CVAT_ALLOW_STATIC_CACHE:-no}' + + cvat_worker_annotation: + environment: + CVAT_ALLOW_STATIC_CACHE: '${CVAT_ALLOW_STATIC_CACHE:-no}' diff --git a/tests/python/rest_api/test_jobs.py b/tests/python/rest_api/test_jobs.py index 1cf761270f38..167b0d63c3b3 100644 --- a/tests/python/rest_api/test_jobs.py +++ b/tests/python/rest_api/test_jobs.py @@ -361,7 +361,7 @@ def _test_destroy_job_fails(self, user, job_id, *, expected_status: int, **kwarg assert response.status == expected_status return response - @pytest.mark.usefixtures("restore_cvat_data") + @pytest.mark.usefixtures("restore_cvat_data_per_function") @pytest.mark.parametrize("job_type, allow", (("ground_truth", True), ("annotation", False))) def test_destroy_job(self, admin_user, jobs, job_type, allow): job = next(j for j in jobs if j["type"] == job_type) diff --git a/tests/python/rest_api/test_queues.py b/tests/python/rest_api/test_queues.py index a1729cf6f253..5d0a3190f16e 100644 --- a/tests/python/rest_api/test_queues.py +++ b/tests/python/rest_api/test_queues.py @@ -18,7 +18,7 @@ @pytest.mark.usefixtures("restore_db_per_function") -@pytest.mark.usefixtures("restore_cvat_data") +@pytest.mark.usefixtures("restore_cvat_data_per_function") @pytest.mark.usefixtures("restore_redis_inmem_per_function") class TestRQQueueWorking: _USER_1 = "admin1" diff --git a/tests/python/rest_api/test_resource_import_export.py b/tests/python/rest_api/test_resource_import_export.py index 833661fcfab8..39f4be22a011 100644 --- a/tests/python/rest_api/test_resource_import_export.py +++ b/tests/python/rest_api/test_resource_import_export.py @@ -177,7 +177,7 @@ def test_user_cannot_export_to_cloud_storage_with_specific_location_without_acce @pytest.mark.usefixtures("restore_db_per_function") -@pytest.mark.usefixtures("restore_cvat_data") +@pytest.mark.usefixtures("restore_cvat_data_per_function") class TestImportResourceFromS3(_S3ResourceTest): @pytest.mark.usefixtures("restore_redis_inmem_per_function") @pytest.mark.parametrize("cloud_storage_id", [3]) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 4d01a93f2448..236270efea53 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -910,7 +910,7 @@ def test_uses_subset_name( @pytest.mark.usefixtures("restore_db_per_function") -@pytest.mark.usefixtures("restore_cvat_data") +@pytest.mark.usefixtures("restore_cvat_data_per_function") @pytest.mark.usefixtures("restore_redis_ondisk_per_function") class TestPostTaskData: _USERNAME = "admin1" @@ -2107,7 +2107,7 @@ def read_frame(self, i: int) -> Image.Image: @pytest.mark.usefixtures("restore_db_per_class") @pytest.mark.usefixtures("restore_redis_ondisk_per_class") -@pytest.mark.usefixtures("restore_cvat_data") +@pytest.mark.usefixtures("restore_cvat_data_per_function") class TestTaskData: _USERNAME = "admin1" @@ -2712,7 +2712,7 @@ def test_admin_can_add_skeleton(self, tasks, admin_user): @pytest.mark.usefixtures("restore_db_per_function") -@pytest.mark.usefixtures("restore_cvat_data") +@pytest.mark.usefixtures("restore_cvat_data_per_function") @pytest.mark.usefixtures("restore_redis_ondisk_per_function") class TestWorkWithTask: _USERNAME = "admin1" @@ -2772,7 +2772,13 @@ def _make_client(self) -> Client: return Client(BASE_URL, config=Config(status_check_period=0.01)) @pytest.fixture(autouse=True) - def setup(self, restore_db_per_function, restore_cvat_data, tmp_path: Path, admin_user: str): + def setup( + self, + restore_db_per_function, + restore_cvat_data_per_function, + tmp_path: Path, + admin_user: str, + ): self.tmp_dir = tmp_path self.client = self._make_client() diff --git a/tests/python/shared/fixtures/init.py b/tests/python/shared/fixtures/init.py index cf5aeabbbf37..99f1f02f8e0b 100644 --- a/tests/python/shared/fixtures/init.py +++ b/tests/python/shared/fixtures/init.py @@ -31,6 +31,7 @@ "tests/docker-compose.file_share.yml", "tests/docker-compose.minio.yml", "tests/docker-compose.test_servers.yml", + "tests/docker-compose.configurable_static_cache.yml", ] @@ -559,7 +560,7 @@ def restore_db_per_class(request): @pytest.fixture(scope="function") -def restore_cvat_data(request): +def restore_cvat_data_per_function(request): platform = request.config.getoption("--platform") if platform == "local": docker_restore_data_volumes() From 2b6e98761d92dcb111ce27f81fc21998819df07b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 28 Aug 2024 18:48:17 +0300 Subject: [PATCH 112/227] Remove extra import --- cvat/apps/engine/cache.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index e5b09c72c256..3c1b54e2cd42 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -21,7 +21,6 @@ import cv2 import PIL.Image import PIL.ImageOps -from django.conf import settings from django.core.cache import caches from rest_framework.exceptions import NotFound, ValidationError From 5986f6367323499b7e4865ba11dde10ce979e437 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 30 Aug 2024 21:20:03 +0300 Subject: [PATCH 113/227] Support backups --- cvat/apps/dataset_manager/project.py | 2 +- cvat/apps/engine/backup.py | 254 ++++++++++++++++++++------- cvat/apps/engine/models.py | 20 ++- cvat/apps/engine/serializers.py | 81 ++++----- cvat/apps/engine/task.py | 127 +++++++++++--- cvat/apps/engine/views.py | 5 +- 6 files changed, 350 insertions(+), 139 deletions(-) diff --git a/cvat/apps/dataset_manager/project.py b/cvat/apps/dataset_manager/project.py index 7579f241043a..a6f6e467f689 100644 --- a/cvat/apps/dataset_manager/project.py +++ b/cvat/apps/dataset_manager/project.py @@ -102,7 +102,7 @@ def split_name(file): data['stop_frame'] = None data['server_files'] = list(map(split_name, data['server_files'])) - create_task(db_task, data, isDatasetImport=True) + create_task(db_task, data, is_dataset_import=True) self.db_tasks = models.Task.objects.filter(project__id=self.db_project.id).exclude(data=None).order_by('id') self.init_from_db() if project_data is not None: diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index bd3918a037af..aa06c71c95a5 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -3,18 +3,19 @@ # # SPDX-License-Identifier: MIT -from logging import Logger import io +import itertools +import mimetypes import os -from enum import Enum import re import shutil import tempfile -from typing import Any, Dict, Iterable import uuid -import mimetypes -from zipfile import ZipFile +from enum import Enum +from logging import Logger from tempfile import NamedTemporaryFile +from typing import Any, Collection, Dict, Iterable, Optional, Union +from zipfile import ZipFile import django_rq from django.conf import settings @@ -30,10 +31,11 @@ import cvat.apps.dataset_manager as dm from cvat.apps.engine import models from cvat.apps.engine.log import ServerLogManager -from cvat.apps.engine.serializers import (AttributeSerializer, DataSerializer, - JobWriteSerializer, LabelSerializer, AnnotationGuideWriteSerializer, AssetWriteSerializer, +from cvat.apps.engine.serializers import (AttributeSerializer, DataSerializer, JobWriteSerializer, + LabelSerializer, AnnotationGuideWriteSerializer, AssetWriteSerializer, LabeledDataSerializer, SegmentSerializer, SimpleJobSerializer, TaskReadSerializer, - ProjectReadSerializer, ProjectFileSerializer, TaskFileSerializer, RqIdSerializer) + ProjectReadSerializer, ProjectFileSerializer, TaskFileSerializer, RqIdSerializer, + ValidationLayoutParamsSerializer) from cvat.apps.engine.utils import ( av_scan_paths, process_failed_job, get_rq_job_meta, import_resource_with_clean_up_after, @@ -174,6 +176,8 @@ def _prepare_attribute_meta(self, attribute): class _TaskBackupBase(_BackupBase): MANIFEST_FILENAME = 'task.json' + MEDIA_MANIFEST_FILENAME = 'manifest.jsonl' + MEDIA_MANIFEST_INDEX_FILENAME = 'index.json' ANNOTATIONS_FILENAME = 'annotations.json' DATA_DIRNAME = 'data' TASK_DIRNAME = 'task' @@ -203,9 +207,17 @@ def _prepare_data_meta(self, data): 'deleted_frames', 'custom_segments', 'job_file_mapping', + 'validation_layout' } self._prepare_meta(allowed_fields, data) + + if 'validation_layout' in data: + self._prepare_meta( + allowed_keys={'mode', 'frames', 'frames_per_job_count'}, + meta=data['validation_layout'] + ) + if 'frame_filter' in data and not data['frame_filter']: data.pop('frame_filter') @@ -327,7 +339,14 @@ def _write_directory(self, source_dir, zip_object, target_dir, recursive=True, e class TaskExporter(_ExporterBase, _TaskBackupBase): def __init__(self, pk, version=Version.V1): super().__init__(logger=slogger.task[pk]) - self._db_task = models.Task.objects.prefetch_related('data__images', 'annotation_guide__assets').select_related('data__video', 'annotation_guide').get(pk=pk) + + self._db_task = ( + models.Task.objects + .prefetch_related('data__images', 'annotation_guide__assets') + .select_related('data__video', 'data__validation_layout', 'annotation_guide') + .get(pk=pk) + ) + self._db_data = self._db_task.data self._version = version @@ -342,17 +361,31 @@ def _write_annotation_guide(self, zip_object, target_dir=None): def _write_data(self, zip_object, target_dir=None): target_data_dir = os.path.join(target_dir, self.DATA_DIRNAME) if target_dir else self.DATA_DIRNAME if self._db_data.storage == StorageChoice.LOCAL: - self._write_directory( + data_dir = self._db_data.get_upload_dirname() + if hasattr(self._db_data, 'video'): + media_files = (os.path.join(data_dir, self._db_data.video.path), ) + else: + media_files = ( + os.path.join(data_dir, im.path) + for im in self._db_data.images.exclude(is_placeholder=True).all() + ) + + data_manifest_path = self._db_data.get_manifest_path() + if os.path.isfile(data_manifest_path): + media_files = itertools.chain(media_files, [self._db_data.get_manifest_path()]) + + self._write_files( source_dir=self._db_data.get_upload_dirname(), zip_object=zip_object, target_dir=target_data_dir, + files=media_files, ) elif self._db_data.storage == StorageChoice.SHARE: data_dir = settings.SHARE_ROOT if hasattr(self._db_data, 'video'): media_files = (os.path.join(data_dir, self._db_data.video.path), ) else: - media_files = (os.path.join(data_dir, im.path) for im in self._db_data.images.all().order_by('frame')) + media_files = (os.path.join(data_dir, im.path) for im in self._db_data.images.all()) self._write_files( source_dir=data_dir, @@ -361,11 +394,10 @@ def _write_data(self, zip_object, target_dir=None): target_dir=target_data_dir, ) - upload_dir = self._db_data.get_upload_dirname() self._write_files( - source_dir=upload_dir, + source_dir=self._db_data.get_upload_dirname(), zip_object=zip_object, - files=(os.path.join(upload_dir, f) for f in ('manifest.jsonl',)), + files=[self._db_data.get_manifest_path()], target_dir=target_data_dir, ) else: @@ -409,8 +441,14 @@ def serialize_segment(db_segment): segment_type = segment.pop("type") segment.update(job_data) - if self._db_task.segment_size == 0 and segment_type == models.SegmentType.RANGE: - segment.update(serialize_custom_file_mapping(db_segment)) + if ( + self._db_task.segment_size == 0 and segment_type == models.SegmentType.RANGE or + ( + hasattr(self._db_data, 'validation_layout') and + self._db_data.validation_layout.mode == models.ValidationMode.GT_POOL + ) + ): + segment.update(serialize_segment_file_names(db_segment)) return segment @@ -419,11 +457,10 @@ def serialize_jobs(): db_segments.sort(key=lambda i: i.job_set.first().id) return (serialize_segment(s) for s in db_segments) - def serialize_custom_file_mapping(db_segment: models.Segment): + def serialize_segment_file_names(db_segment: models.Segment): if self._db_task.mode == 'annotation': - files: Iterable[models.Image] = self._db_data.images.all().order_by('frame') - segment_files = files[db_segment.start_frame : db_segment.stop_frame + 1] - return {'files': list(frame.path for frame in segment_files)} + files: Iterable[models.Image] = self._db_data.images.order_by('frame').all() + return {'files': [files[f].path for f in sorted(db_segment.frame_set)]} else: assert False, ( "Backups with custom file mapping are not supported" @@ -441,6 +478,22 @@ def serialize_data(): if self._db_task.segment_size == 0: data['custom_segments'] = True + if ( + (validation_layout := getattr(self._db_data, 'validation_layout', None)) and + validation_layout.mode == models.ValidationMode.GT_POOL + ): + validation_layout_serializer = ValidationLayoutParamsSerializer( + instance=validation_layout + ) + validation_layout_params = validation_layout_serializer.data + validation_layout_params['frames'] = list( + validation_layout.frames + .order_by('path') + .values_list('path', flat=True) + .iterator(chunk_size=10000) + ) + data['validation_layout'] = validation_layout_params + return self._prepare_data_meta(data) task = serialize_task() @@ -602,7 +655,7 @@ def _calculate_segment_size(jobs): return segment_size, overlap @staticmethod - def _parse_custom_segments(*, jobs: Dict[str, Any]) -> JobFileMapping: + def _parse_segment_frames(*, jobs: Dict[str, Any]) -> JobFileMapping: segments = [] for i, segment in enumerate(jobs): @@ -615,29 +668,57 @@ def _parse_custom_segments(*, jobs: Dict[str, Any]) -> JobFileMapping: return segments - def _import_task(self): - def _write_data(zip_object): - data_path = self._db_task.data.get_upload_dirname() - task_dirname = os.path.join(self._subdir, self.TASK_DIRNAME) if self._subdir else self.TASK_DIRNAME - data_dirname = os.path.join(self._subdir, self.DATA_DIRNAME) if self._subdir else self.DATA_DIRNAME - uploaded_files = [] - for f in zip_object.namelist(): - if f.endswith(os.path.sep): - continue - if f.startswith(data_dirname + os.path.sep): - target_file = os.path.join(data_path, os.path.relpath(f, data_dirname)) - self._prepare_dirs(target_file) - with open(target_file, "wb") as out: - out.write(zip_object.read(f)) - uploaded_files.append(os.path.relpath(f, data_dirname)) - elif f.startswith(task_dirname + os.path.sep): - target_file = os.path.join(task_path, os.path.relpath(f, task_dirname)) - self._prepare_dirs(target_file) - with open(target_file, "wb") as out: - out.write(zip_object.read(f)) - - return uploaded_files + def _copy_input_files( + self, + input_archive: Union[ZipFile, str], + output_task_path: str, + *, + excluded_filenames: Optional[Collection[str]] = None, + ) -> list[str]: + if isinstance(input_archive, str): + with ZipFile(input_archive, 'r') as zf: + return self._copy_input_files( + input_archive=zf, + output_task_path=output_task_path, + excluded_filenames=excluded_filenames, + ) + + input_task_dirname = self.TASK_DIRNAME + input_data_dirname = self.DATA_DIRNAME + output_data_path = self._db_task.data.get_upload_dirname() + uploaded_files = [] + for fn in input_archive.namelist(): + if fn.endswith(os.path.sep) or ( + self._subdir and not fn.startswith(self._subdir + os.path.sep) + ): + continue + + fn = os.path.relpath(fn, self._subdir) + if excluded_filenames and fn in excluded_filenames: + continue + + if fn.startswith(input_data_dirname + os.path.sep): + target_file = os.path.join( + output_data_path, os.path.relpath(fn, input_data_dirname) + ) + + self._prepare_dirs(target_file) + with open(target_file, "wb") as out: + out.write(input_archive.read(fn)) + + uploaded_files.append(os.path.relpath(fn, input_data_dirname)) + elif fn.startswith(input_task_dirname + os.path.sep): + target_file = os.path.join( + output_task_path, os.path.relpath(fn, input_task_dirname) + ) + self._prepare_dirs(target_file) + with open(target_file, "wb") as out: + out.write(input_archive.read(fn)) + + return uploaded_files + + def _import_task(self): data = self._manifest.pop('data') labels = self._manifest.pop('labels') jobs = self._manifest.pop('jobs') @@ -646,9 +727,15 @@ def _write_data(zip_object): self._manifest['owner_id'] = self._user_id self._manifest['project_id'] = self._project_id - if custom_segments := data.pop('custom_segments', False): - job_file_mapping = self._parse_custom_segments(jobs=jobs) - data['job_file_mapping'] = job_file_mapping + self._prepare_data_meta(data) + + excluded_input_files = [os.path.join(self.DATA_DIRNAME, self.MEDIA_MANIFEST_INDEX_FILENAME)] + + job_file_mapping = None + if data.pop('custom_segments', False): + job_file_mapping = self._parse_segment_frames(jobs=[ + v for v in jobs if v.get('type') != models.JobType.GROUND_TRUTH + ]) for d in [self._manifest, data]: for k in [ @@ -660,48 +747,85 @@ def _write_data(zip_object): self._manifest['segment_size'], self._manifest['overlap'] = \ self._calculate_segment_size(jobs) + validation_params = data.pop('validation_layout', None) + if validation_params: + validation_params['frame_selection_method'] = models.JobFrameSelectionMethod.MANUAL + validation_params_serializer = ValidationLayoutParamsSerializer(data=validation_params) + validation_params_serializer.is_valid(raise_exception=True) + validation_params = validation_params_serializer.data + + gt_jobs = [v for v in jobs if v.get('type') == models.JobType.GROUND_TRUTH] + if not gt_jobs: + raise ValidationError("Can't find any GT jobs info in the backup files") + elif len(gt_jobs) != 1: + raise ValidationError("A task can have only one GT job info in the backup files") + + validation_params['frames'] = validation_params_serializer.initial_data['frames'] + + if validation_params['mode'] == models.ValidationMode.GT_POOL: + gt_job_frames = self._parse_segment_frames(jobs=gt_jobs)[0] + if set(gt_job_frames) != set(validation_params_serializer.initial_data['frames']): + raise ValidationError("GT job frames do not match validation frames") + + # Validation frames can have a different order, we must use the GT job order + if not job_file_mapping: + raise ValidationError("Expected segment info in the backup files") + + job_file_mapping.append(gt_job_frames) + + data['validation_params'] = validation_params + + if job_file_mapping and ( + not validation_params or validation_params['mode'] != models.ValidationMode.GT_POOL + ): + # It's currently prohibited not allowed to have repeated file names in jobs. + # DataSerializer checks it, but we don't need it for tasks with a GT pool + data['job_file_mapping'] = job_file_mapping + self._db_task = models.Task.objects.create(**self._manifest, organization_id=self._org_id) - task_path = self._db_task.get_dirname() - if os.path.isdir(task_path): - shutil.rmtree(task_path) - os.makedirs(task_path) + task_data_path = self._db_task.get_dirname() + if os.path.isdir(task_data_path): + shutil.rmtree(task_data_path) + os.makedirs(task_data_path) if not self._labels_mapping: self._labels_mapping = self._create_labels(db_task=self._db_task, labels=labels) - self._prepare_data_meta(data) data_serializer = DataSerializer(data=data) data_serializer.is_valid(raise_exception=True) db_data = data_serializer.save() self._db_task.data = db_data self._db_task.save() - if isinstance(self._file, str): - with ZipFile(self._file, 'r') as zf: - uploaded_files = _write_data(zf) - else: - uploaded_files = _write_data(self._file) + uploaded_files = self._copy_input_files( + self._file, task_data_path, excluded_filenames=excluded_input_files + ) data['use_zip_chunks'] = data.pop('chunk_type') == DataChoice.IMAGESET data = data_serializer.data data['client_files'] = uploaded_files - if custom_segments: + + if job_file_mapping and ( + validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL + ): data['job_file_mapping'] = job_file_mapping - _create_thread(self._db_task.pk, data.copy(), isBackupRestore=True) + if validation_params: + data['validation_params'] = validation_params + + _create_thread(self._db_task.pk, data.copy(), is_backup_restore=True) self._db_task.refresh_from_db() db_data.refresh_from_db() - db_data.start_frame = data['start_frame'] - db_data.stop_frame = data['stop_frame'] - db_data.frame_filter = data['frame_filter'] db_data.deleted_frames = data_serializer.initial_data.get('deleted_frames', []) db_data.storage = StorageChoice.LOCAL - db_data.save(update_fields=['start_frame', 'stop_frame', 'frame_filter', 'storage', 'deleted_frames']) + db_data.save(update_fields=['storage', 'deleted_frames']) - # Recreate Ground Truth jobs (they won't be created automatically) - self._import_gt_jobs(jobs) + if not validation_params: + # In backups created before addition of GT pools there was no validation_layout field + # Recreate Ground Truth jobs + self._import_gt_jobs(jobs) for db_job, job in zip(self._get_db_jobs(), jobs): db_job.status = job['status'] @@ -709,7 +833,7 @@ def _write_data(zip_object): def _import_gt_jobs(self, jobs): for job in jobs: - # The type field will be missing in backups create before the GT jobs were introduced + # The type field will be missing in backups created before the GT jobs were introduced try: raw_job_type = job.get("type", models.JobType.ANNOTATION.value) job_type = models.JobType(raw_job_type) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index e7cdab2940a2..764dbf9f3683 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -11,7 +11,7 @@ import uuid from enum import Enum from functools import cached_property -from typing import Any, Collection, Dict, Optional +from typing import Any, ClassVar, Collection, Dict, Optional from django.conf import settings from django.contrib.auth.models import User @@ -259,6 +259,8 @@ class ValidationFrame(models.Model): path = models.CharField(max_length=1024, default='') class Data(models.Model): + MANIFEST_FILENAME: ClassVar[str] = 'manifest.jsonl' + chunk_size = models.PositiveIntegerField(null=True) size = models.PositiveIntegerField(default=0) image_quality = models.PositiveSmallIntegerField(default=50) @@ -274,7 +276,7 @@ class Data(models.Model): cloud_storage = models.ForeignKey('CloudStorage', on_delete=models.SET_NULL, null=True, related_name='data') sorting_method = models.CharField(max_length=15, choices=SortingMethod.choices(), default=SortingMethod.LEXICOGRAPHICAL) deleted_frames = IntArrayField(store_sorted=True, unique_values=True) - validation_layout: ValidationLayout + validation_layout: ValidationLayout # TODO: maybe allow None to avoid hasattr everywhere class Meta: default_permissions = () @@ -292,6 +294,13 @@ def get_data_dirname(self): def get_upload_dirname(self): return os.path.join(self.get_data_dirname(), "raw") + def get_raw_data_dirname(self) -> str: + return { + StorageChoice.LOCAL: self.get_upload_dirname(), + StorageChoice.SHARE: settings.SHARE_ROOT, + StorageChoice.CLOUD_STORAGE: self.get_upload_dirname(), + }[self.storage] + def get_compressed_cache_dirname(self): return os.path.join(self.get_data_dirname(), "compressed") @@ -323,11 +332,8 @@ def get_compressed_segment_chunk_path(self, chunk_number: int, segment_id: int) return os.path.join(self.get_compressed_cache_dirname(), self._get_compressed_chunk_name(segment_id, chunk_number)) - def get_manifest_path(self): - return os.path.join(self.get_upload_dirname(), 'manifest.jsonl') - - def get_index_path(self): - return os.path.join(self.get_upload_dirname(), 'index.json') + def get_manifest_path(self) -> str: + return os.path.join(self.get_upload_dirname(), self.MANIFEST_FILENAME) def make_dirs(self): data_path = self.get_data_dirname() diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 40557731adc7..ef8b27180a7f 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -728,7 +728,7 @@ def validate(self, attrs): attrs, ['frames_per_job_count', 'frames_per_job_share'] ) elif attrs['frame_selection_method'] == models.JobFrameSelectionMethod.MANUAL: - field_validation.require_field("frames") + field_validation.require_field(attrs, "frames") if ( attrs['frame_selection_method'] != models.JobFrameSelectionMethod.MANUAL and @@ -850,6 +850,32 @@ def create(self, validated_data): f"Unexpected frame selection method '{frame_selection_method}'" ) + # Update validation layout in the task + frame_paths = list( + models.Image.objects + .order_by('frame') + .values_list('path', flat=True) + .iterator(chunk_size=10000) + ) + validation_layout_params = { + "mode": models.ValidationMode.GT, + "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, + "frames": [frame_paths[frame_id] for frame_id in frames], + + # reset other fields + "random_seed": None, + "frame_count": None, + "frame_share": None, + "frames_per_job_count": None, + "frames_per_job_share": None, + } + validation_layout_serializer = ValidationLayoutParamsSerializer( + instance=getattr(task.data, 'validation_layout', None), data=validation_layout_params + ) + assert validation_layout_serializer.is_valid(raise_exception=False) + validation_layout_serializer.save(task_data=task.data) + + # Save the new job segment = models.Segment.objects.create( start_frame=0, stop_frame=task.data.size - 1, @@ -872,28 +898,6 @@ def create(self, validated_data): job.make_dirs() - # Update validation layout in the task - validation_layout_params = { - "mode": models.ValidationMode.GT, - "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, - - # reset other fields - "random_seed": None, - "frame_count": None, - "frame_share": None, - "frames_per_job_count": None, - "frames_per_job_share": None, - } - validation_layout_serializer = ValidationLayoutParamsSerializer() - if not hasattr(task.data, 'validation_layout'): - validation_layout = validation_layout_serializer.create(validation_layout_params) - validation_layout.task_data_id = task.data_id - validation_layout.save(update_fields=["task_data_id"]) - else: - validation_layout_serializer.update( - task.data.validation_layout, validation_layout_params - ) - return job def update(self, instance, validated_data): @@ -1065,6 +1069,7 @@ class ValidationLayoutParamsSerializer(serializers.ModelSerializer): choices=models.JobFrameSelectionMethod.choices(), required=True ) frames = serializers.ListField( + write_only=True, child=serializers.CharField(max_length=MAX_FILENAME_LENGTH), default=None, required=False, @@ -1162,7 +1167,7 @@ def validate(self, attrs): attrs, ['frames_per_job_count', 'frames_per_job_share'] ) elif attrs['frame_selection_method'] == models.JobFrameSelectionMethod.MANUAL: - field_validation.require_field("frames") + field_validation.require_field(attrs, "frames") if ( attrs['frame_selection_method'] != models.JobFrameSelectionMethod.MANUAL and @@ -1372,6 +1377,7 @@ def validate(self, attrs): @transaction.atomic def create(self, validated_data): files = self._pop_data(validated_data) + validation_params = validated_data.pop('validation_params', None) db_data = models.Data.objects.create(**validated_data) db_data.make_dirs() @@ -1380,11 +1386,10 @@ def create(self, validated_data): db_data.save() - validation_params = validated_data.pop('validation_params', None) - if validation_params.get("mode"): - validation_params["task_data"] = db_data - validation_layout_params_serializer = ValidationLayoutParamsSerializer() - validation_layout_params_serializer.create(validation_params) + if validation_params: + validation_params_serializer = ValidationLayoutParamsSerializer(data=validation_params) + validation_params_serializer.is_valid(raise_exception=True) + db_data.validation_layout = validation_params_serializer.save(task_data=db_data) return db_data @@ -1400,21 +1405,11 @@ def update(self, instance, validated_data): instance.save() if validation_params: - db_validation_layout = getattr(instance, "validation_layout", None) - validation_layout_params_serializer = ValidationLayoutParamsSerializer( - instance=db_validation_layout + validation_params_serializer = ValidationLayoutParamsSerializer( + instance=getattr(instance, "validation_layout", None), data=validation_params ) - if not db_validation_layout: - validation_params["task_data"] = instance - db_validation_layout = validation_layout_params_serializer.create( - validation_params - ) - else: - db_validation_layout = validation_layout_params_serializer.update( - db_validation_layout, validation_params - ) - - instance.validation_layout = db_validation_layout + validation_params_serializer.is_valid(raise_exception=True) + instance.validation_layout = validation_params_serializer.save(task_data=instance) return instance diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 9f8d6d34680f..9288318252db 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -7,7 +7,6 @@ import itertools import fnmatch import os -import rq import re import rq import shutil @@ -24,6 +23,7 @@ import av import attrs import django_rq +from datumaro.util import take_by from django.conf import settings from django.db import transaction from django.forms.models import model_to_dict @@ -38,6 +38,7 @@ ValidateDimension, ZipChunkWriter, ZipCompressedChunkWriter, get_mime, sort ) from cvat.apps.engine.models import RequestAction, RequestTarget +from cvat.apps.engine.serializers import ValidationLayoutParamsSerializer from cvat.apps.engine.utils import ( av_scan_paths, format_list,get_rq_job_meta, define_dependent_job, get_rq_lock_by_user, preload_images ) @@ -309,7 +310,8 @@ def _validate_job_file_mapping( if job_file_mapping is None: return None - elif not list(itertools.chain.from_iterable(job_file_mapping)): + + if not list(itertools.chain.from_iterable(job_file_mapping)): raise ValidationError("job_file_mapping cannot be empty") if db_task.segment_size: @@ -341,7 +343,7 @@ def _validate_job_file_mapping( return job_file_mapping def _validate_validation_params( - db_task: models.Task, data: Dict[str, Any] + db_task: models.Task, data: Dict[str, Any], *, is_backup_restore: bool = False ) -> Optional[dict[str, Any]]: params = data.get('validation_params', {}) if not params: @@ -358,13 +360,19 @@ def _validate_validation_params( if params['mode'] != models.ValidationMode.GT_POOL: return params - if data.get('sorting_method', db_task.data.sorting_method) != models.SortingMethod.RANDOM: + if ( + data.get('sorting_method', db_task.data.sorting_method) != models.SortingMethod.RANDOM and + not is_backup_restore + ): raise ValidationError('validation mode "{}" can only be used with "{}" sorting'.format( models.ValidationMode.GT_POOL.value, models.SortingMethod.RANDOM.value, )) for incompatible_key in ['job_file_mapping', 'overlap']: + if incompatible_key == 'job_file_mapping' and is_backup_restore: + continue + if data.get(incompatible_key): raise ValidationError('validation mode "{}" cannot be used with "{}"'.format( models.ValidationMode.GT_POOL.value, @@ -551,8 +559,8 @@ def _create_thread( db_task: Union[int, models.Task], data: Dict[str, Any], *, - isBackupRestore: bool = False, - isDatasetImport: bool = False, + is_backup_restore: bool = False, + is_dataset_import: bool = False, ) -> None: if isinstance(db_task, int): db_task = models.Task.objects.select_for_update().get(pk=db_task) @@ -566,13 +574,16 @@ def _update_status(msg: str) -> None: job.save_meta() job_file_mapping = _validate_job_file_mapping(db_task, data) - validation_params = _validate_validation_params(db_task, data) + + validation_params = _validate_validation_params( + db_task, data, is_backup_restore=is_backup_restore + ) db_data = db_task.data upload_dir = db_data.get_upload_dirname() if db_data.storage != models.StorageChoice.SHARE else settings.SHARE_ROOT is_data_in_cloud = db_data.storage == models.StorageChoice.CLOUD_STORAGE - if data['remote_files'] and not isDatasetImport: + if data['remote_files'] and not is_dataset_import: data['remote_files'] = _download_data(data['remote_files'], upload_dir) # find and validate manifest file @@ -788,12 +799,12 @@ def _update_status(msg: str) -> None: ) media['directory'] = [] - if (not isBackupRestore and manifest_file and + if (not is_backup_restore and manifest_file and data['sorting_method'] == models.SortingMethod.RANDOM ): raise ValidationError("It isn't supported to upload manifest file and use random sorting") - if (isBackupRestore and db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM and + if (is_backup_restore and db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM and data['sorting_method'] in {models.SortingMethod.RANDOM, models.SortingMethod.PREDEFINED} ): raise ValidationError( @@ -811,7 +822,7 @@ def _update_status(msg: str) -> None: if extractor is not None: raise ValidationError('Combined data types are not supported') - if (isDatasetImport or isBackupRestore) and media_type == 'image' and db_data.storage == models.StorageChoice.SHARE: + if (is_dataset_import or is_backup_restore) and media_type == 'image' and db_data.storage == models.StorageChoice.SHARE: manifest_index = _get_manifest_frame_indexer(db_data.start_frame, db_data.get_frame_step()) db_data.start_frame = 0 data['stop_frame'] = None @@ -880,7 +891,7 @@ def _update_status(msg: str) -> None: # When a task is created, the sorting method can be random and in this case, reinitialization will be with correct sorting # but when a task is restored from a backup, a random sorting is changed to predefined and we need to manually sort files # in the correct order. - source_files = absolute_keys_of_related_files if not isBackupRestore else \ + source_files = absolute_keys_of_related_files if not is_backup_restore else \ [item for item in extractor.absolute_source_paths if item in absolute_keys_of_related_files] extractor.reconcile( source_files=source_files, @@ -898,12 +909,12 @@ def _update_status(msg: str) -> None: if validate_dimension.dimension != models.DimensionType.DIM_3D and ( ( not isinstance(extractor, MEDIA_TYPES['video']['extractor']) and - isBackupRestore and + is_backup_restore and db_data.storage_method == models.StorageMethodChoice.CACHE and db_data.sorting_method in {models.SortingMethod.RANDOM, models.SortingMethod.PREDEFINED} ) or ( - not isDatasetImport and - not isBackupRestore and + not is_dataset_import and + not is_backup_restore and data['sorting_method'] == models.SortingMethod.PREDEFINED and ( # Sorting with manifest is required for zip isinstance(extractor, MEDIA_TYPES['zip']['extractor']) or @@ -1128,7 +1139,46 @@ def _update_status(msg: str) -> None: # TODO: refactor # Prepare jobs - if validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: + if validation_params and ( + validation_params['mode'] == models.ValidationMode.GT_POOL and is_backup_restore + ): + # Validation frames must be in the end of the images list. Collect their ids + frame_idx_map: dict[str, int] = {} + for i, frame_name in enumerate(validation_params['frames']): + image = images[-len(validation_params['frames']) + i] + assert frame_name == image.path + frame_idx_map[image.path] = image.frame + + # Store information about the real frame placement in validation frames in jobs + for image in images: + real_frame_idx = frame_idx_map.get(image.path) + if real_frame_idx is not None: + image.is_placeholder = True + image.real_frame_id = real_frame_idx + + # Exclude the previous GT job from the list of jobs to be created with normal segments + # It must be the last one + assert job_file_mapping[-1] == validation_params['frames'] + job_file_mapping.pop(-1) + + validation_frames = list(frame_idx_map.values()) + + # Save the created validation layout + validation_params = { + "mode": models.ValidationMode.GT_POOL, + "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, + "frames": [images[frame_id].path for frame_id in validation_frames], + "frames_per_job_count": validation_params['frames_per_job_count'], + + # reset other fields + "random_seed": None, + "frame_count": None, + "frame_share": None, + "frames_per_job_share": None, + } + validation_layout_serializer = ValidationLayoutParamsSerializer() + validation_layout_serializer.update(db_data.validation_layout, validation_params) + elif validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: if db_task.mode != 'annotation': raise ValidationError( f"validation mode '{models.ValidationMode.GT_POOL}' can only be used " @@ -1179,11 +1229,7 @@ def _update_status(msg: str) -> None: case _: assert False - non_pool_frames = set(all_frames).difference(pool_frames) - # 2. distribute pool frames - from datumaro.util import take_by - if frames_per_job_count := validation_params.get("frames_per_job_count"): if len(pool_frames) < frames_per_job_count and validation_params.get("frame_count"): raise ValidationError( @@ -1202,6 +1248,7 @@ def _update_status(msg: str) -> None: new_db_images: list[models.Image] = [] validation_frames: list[int] = [] frame_idx_map: dict[int, int] = {} # new to original id + non_pool_frames = set(all_frames).difference(pool_frames) for job_frames in take_by(non_pool_frames, count=db_task.segment_size or db_data.size): job_validation_frames = rng.choice( pool_frames, size=frames_per_job_count, replace=False @@ -1272,6 +1319,24 @@ def _update_status(msg: str) -> None: validation_frames = pool_frames + # Save the created validation layout + # TODO: try to find a way to avoid using the same model for storing the user request + # and internal data + validation_params = { + "mode": models.ValidationMode.GT_POOL, + "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, + "frames": [new_db_images[frame_id].path for frame_id in validation_frames], + "frames_per_job_count": frames_per_job_count, + + # reset other fields + "random_seed": None, + "frame_count": None, + "frame_share": None, + "frames_per_job_share": None, + } + validation_layout_serializer = ValidationLayoutParamsSerializer() + validation_layout_serializer.update(db_data.validation_layout, validation_params) + if db_task.mode == 'annotation': models.Image.objects.bulk_create(images) images = models.Image.objects.filter(data_id=db_data.id) @@ -1366,12 +1431,30 @@ def _update_status(msg: str) -> None: f'Unknown frame selection method {validation_params["frame_selection_method"]}' ) + # Save the created validation layout + # TODO: try to find a way to avoid using the same model for storing the user request + # and internal data + validation_params = { + "mode": models.ValidationMode.GT, + "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, + "frames": [new_db_images[frame_id].path for frame_id in validation_frames], + + # reset other fields + "random_seed": None, + "frame_count": None, + "frame_share": None, + "frames_per_job_count": None, + "frames_per_job_share": None, + } + validation_layout_serializer = ValidationLayoutParamsSerializer() + validation_layout_serializer.update(db_data.validation_layout, validation_params) + # TODO: refactor if validation_params: db_gt_segment = models.Segment( task=db_task, start_frame=0, - stop_frame=db_data.stop_frame, + stop_frame=db_data.size - 1, frames=validation_frames, type=models.SegmentType.SPECIFIC_FRAMES, ) @@ -1381,6 +1464,8 @@ def _update_status(msg: str) -> None: db_gt_job.save() db_gt_job.make_dirs() + db_task.save() + if ( settings.MEDIA_CACHE_ALLOW_STATIC_CACHE and db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index b0e7657fee4d..8bdec141400e 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -1716,8 +1716,9 @@ def perform_destroy(self, instance): if instance.type != JobType.GROUND_TRUTH: raise ValidationError("Only ground truth jobs can be removed") - if hasattr(instance.segment.task.data, 'validation_layout') and ( - instance.segment.task.data.validation_layout.mode == models.ValidationMode.GT_POOL + if ( + validation_layout := getattr(instance.segment.task.data, 'validation_layout', None) and + validation_layout.mode == models.ValidationMode.GT_POOL ): raise ValidationError( 'GT jobs cannot be removed when task validation mode is "{}"'.format( From 26ffb228b2c06613c059d8f5209124018c14da97 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 2 Sep 2024 14:59:12 +0300 Subject: [PATCH 114/227] Fix restoring from backups --- cvat/apps/engine/backup.py | 10 ++++++++++ cvat/apps/engine/task.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index aa06c71c95a5..2afa19821bf7 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -660,6 +660,16 @@ def _parse_segment_frames(*, jobs: Dict[str, Any]) -> JobFileMapping: for i, segment in enumerate(jobs): segment_size = segment['stop_frame'] - segment['start_frame'] + 1 + if segment_frames := segment.get('frames'): + segment_frames = set(segment_frames) + segment_range = range(segment['start_frame'], segment['stop_frame'] + 1) + if not segment_frames.issubset(segment_range): + raise ValidationError( + "Segment frames must be inside the range [start_frame; stop_frame]" + ) + + segment_size = len(segment_frames) + segment_files = segment['files'] if len(segment_files) != segment_size: raise ValidationError(f"segment {i}: segment files do not match segment size") diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 9288318252db..d69dc3552f04 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1150,7 +1150,7 @@ def _update_status(msg: str) -> None: frame_idx_map[image.path] = image.frame # Store information about the real frame placement in validation frames in jobs - for image in images: + for image in images[:-len(validation_params['frames'])]: real_frame_idx = frame_idx_map.get(image.path) if real_frame_idx is not None: image.is_placeholder = True From 274237c0b6699c67632cc3f0f3d43755f0893754 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 2 Sep 2024 14:59:39 +0300 Subject: [PATCH 115/227] Make field optional in job update checks --- cvat/apps/engine/serializers.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index ef8b27180a7f..e95fa9e81955 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -721,17 +721,18 @@ def to_representation(self, instance): def validate(self, attrs): attrs = field_validation.drop_null_keys(attrs) - if attrs['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_UNIFORM: + frame_selection_method = attrs.get('frame_selection_method') + if frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: field_validation.require_one_of_fields(attrs, ['frame_count', 'frame_share']) - elif attrs['frame_selection_method'] == models.JobFrameSelectionMethod.RANDOM_PER_JOB: + elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_PER_JOB: field_validation.require_one_of_fields( attrs, ['frames_per_job_count', 'frames_per_job_share'] ) - elif attrs['frame_selection_method'] == models.JobFrameSelectionMethod.MANUAL: + elif frame_selection_method == models.JobFrameSelectionMethod.MANUAL: field_validation.require_field(attrs, "frames") if ( - attrs['frame_selection_method'] != models.JobFrameSelectionMethod.MANUAL and + frame_selection_method != models.JobFrameSelectionMethod.MANUAL and attrs.get('frames') ): raise serializers.ValidationError( From 1c36f81b041385c921749e60311ab7fe2958a170 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 2 Sep 2024 19:24:43 +0300 Subject: [PATCH 116/227] Add export support --- cvat/apps/dataset_manager/annotation.py | 67 +++++++++------ cvat/apps/dataset_manager/bindings.py | 13 ++- cvat/apps/dataset_manager/task.py | 105 +++++++++++++++++++----- cvat/apps/dataset_manager/util.py | 16 ++-- 4 files changed, 147 insertions(+), 54 deletions(-) diff --git a/cvat/apps/dataset_manager/annotation.py b/cvat/apps/dataset_manager/annotation.py index d82ce7479563..4084128c2ae2 100644 --- a/cvat/apps/dataset_manager/annotation.py +++ b/cvat/apps/dataset_manager/annotation.py @@ -6,7 +6,7 @@ from copy import copy, deepcopy import math -from typing import Optional, Sequence +from typing import Container, Optional, Sequence import numpy as np from itertools import chain from scipy.optimize import linear_sum_assignment @@ -14,7 +14,7 @@ from cvat.apps.engine.models import ShapeType, DimensionType from cvat.apps.engine.serializers import LabeledDataSerializer -from cvat.apps.dataset_manager.util import deepcopy_simple +from cvat.apps.dataset_manager.util import faster_deepcopy class AnnotationIR: @@ -169,29 +169,43 @@ def reset(self): self.tracks = [] class AnnotationManager: - def __init__(self, data): + def __init__(self, data: AnnotationIR, *, dimension: DimensionType): self.data = data + self.dimension = dimension - def merge(self, data, start_frame, overlap, dimension): - tags = TagManager(self.data.tags) - tags.merge(data.tags, start_frame, overlap, dimension) + def merge(self, data: AnnotationIR, start_frame: int, overlap: int): + tags = TagManager(self.data.tags, dimension=self.dimension) + tags.merge(data.tags, start_frame, overlap) - shapes = ShapeManager(self.data.shapes) - shapes.merge(data.shapes, start_frame, overlap, dimension) + shapes = ShapeManager(self.data.shapes, dimension=self.dimension) + shapes.merge(data.shapes, start_frame, overlap) + + tracks = TrackManager(self.data.tracks, dimension=self.dimension) + tracks.merge(data.tracks, start_frame, overlap) + + def clear_frames(self, frames: Container[int]): + if not isinstance(frames, set): + frames = set(frames) + + tags = TagManager(self.data.tags, dimension=self.dimension) + tags.clear_frames(frames) + + shapes = ShapeManager(self.data.shapes, dimension=self.dimension) + shapes.clear_frames(frames) - tracks = TrackManager(self.data.tracks, dimension) - tracks.merge(data.tracks, start_frame, overlap, dimension) + if self.data.tracks: + # Tracks are not expected in the cases this function is supposed to be used + raise AssertionError("Partial annotation cleanup is not supported for tracks") def to_shapes(self, end_frame: int, - dimension: DimensionType, *, included_frames: Optional[Sequence[int]] = None, include_outside: bool = False, use_server_track_ids: bool = False ) -> list: shapes = self.data.shapes - tracks = TrackManager(self.data.tracks, dimension) + tracks = TrackManager(self.data.tracks, dimension=self.dimension) if included_frames is not None: shapes = [s for s in shapes if s["frame"] in included_frames] @@ -208,8 +222,9 @@ def to_tracks(self): return tracks + shapes.to_tracks() class ObjectManager: - def __init__(self, objects): + def __init__(self, objects, *, dimension: DimensionType): self.objects = objects + self.dimension = dimension @staticmethod def _get_objects_by_frame(objects, start_frame): @@ -238,7 +253,7 @@ def _unite_objects(obj0, obj1): def _modify_unmatched_object(self, obj, end_frame): raise NotImplementedError() - def merge(self, objects, start_frame, overlap, dimension): + def merge(self, objects, start_frame, overlap): # 1. Split objects on two parts: new and which can be intersected # with existing objects. new_objects = [obj for obj in objects @@ -277,7 +292,7 @@ def merge(self, objects, start_frame, overlap, dimension): for i, int_obj in enumerate(int_objects): for j, old_obj in enumerate(old_objects): cost_matrix[i][j] = 1 - self._calc_objects_similarity( - int_obj, old_obj, start_frame, overlap, dimension) + int_obj, old_obj, start_frame, overlap, self.dimension) # 6. Find optimal solution using Hungarian algorithm. row_ind, col_ind = linear_sum_assignment(cost_matrix) @@ -306,6 +321,11 @@ def merge(self, objects, start_frame, overlap, dimension): # We don't have old objects on the frame. Let's add all new ones. self.objects.extend(int_objects_by_frame[frame]) + def clear_frames(self, frames: Container[int]): + new_objects = [obj for obj in self.objects if obj["frame"] not in frames] + self.objects.clear() + self.objects.extend(new_objects) + class TagManager(ObjectManager): @staticmethod def _get_cost_threshold(): @@ -439,10 +459,6 @@ def _modify_unmatched_object(self, obj, end_frame): pass class TrackManager(ObjectManager): - def __init__(self, objects, dimension): - self._dimension = dimension - super().__init__(objects) - def to_shapes(self, end_frame: int, *, included_frames: Optional[Sequence[int]] = None, include_outside: bool = False, @@ -457,7 +473,7 @@ def to_shapes(self, end_frame: int, *, track, 0, end_frame, - self._dimension, + self.dimension, include_outside=include_outside, included_frames=included_frames, ): @@ -475,7 +491,7 @@ def to_shapes(self, end_frame: int, *, continue if track.get("elements"): - track_elements = TrackManager(track["elements"], self._dimension) + track_elements = TrackManager(track["elements"], dimension=self.dimension) element_shapes = track_elements.to_shapes(end_frame, included_frames=set(track_shapes.keys()).intersection(included_frames or []), include_outside=True, # elements are controlled by the parent shape @@ -570,7 +586,7 @@ def get_interpolated_shapes( ): def copy_shape(source, frame, points=None, rotation=None): copied = source.copy() - copied["attributes"] = deepcopy_simple(source["attributes"]) + copied["attributes"] = faster_deepcopy(source["attributes"]) copied["keyframe"] = False copied["frame"] = frame @@ -693,7 +709,7 @@ def match_left_right(left_curve, right_curve): def match_right_left(left_curve, right_curve, left_right_matching): matched_right_points = list(chain.from_iterable(left_right_matching.values())) unmatched_right_points = filter(lambda x: x not in matched_right_points, range(len(right_curve))) - updated_matching = deepcopy_simple(left_right_matching) + updated_matching = faster_deepcopy(left_right_matching) for right_point in unmatched_right_points: left_point = find_nearest_pair(right_curve[right_point], left_curve) @@ -934,7 +950,7 @@ def propagate(shape, end_frame, *, included_frames=None): # Propagate attributes for attr in prev_shape["attributes"]: if attr["spec_id"] not in map(lambda el: el["spec_id"], shape["attributes"]): - shape["attributes"].append(deepcopy_simple(attr)) + shape["attributes"].append(faster_deepcopy(attr)) if not prev_shape["outside"] or include_outside: shapes.extend(interpolate(prev_shape, shape)) @@ -983,3 +999,6 @@ def _unite_objects(obj0, obj1): track["shapes"] = list(sorted(shapes.values(), key=lambda shape: shape["frame"])) return track + + def clear_frames(self, frames: Container[int]): + raise AssertionError("This function is not supported for tracks") diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index f8dd470b64a5..ddd89df2b294 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -430,9 +430,12 @@ def get_frame(idx): for idx in sorted(set(self._frame_info) & included_frames): get_frame(idx) - anno_manager = AnnotationManager(self._annotation_ir) + anno_manager = AnnotationManager( + self._annotation_ir, dimension=self._annotation_ir.dimension + ) for shape in sorted( - anno_manager.to_shapes(self.stop + 1, self._annotation_ir.dimension, + anno_manager.to_shapes( + self.stop + 1, # Skip outside, deleted and excluded frames included_frames=included_frames, include_outside=False, @@ -1174,10 +1177,12 @@ def get_frame(task_id: int, idx: int) -> ProjectData.Frame: get_frame(*ident) for task in self._db_tasks.values(): - anno_manager = AnnotationManager(self._annotation_irs[task.id]) + anno_manager = AnnotationManager( + self._annotation_irs[task.id], dimension=self._annotation_irs[task.id].dimension + ) for shape in sorted( anno_manager.to_shapes( - task.data.size, self._annotation_irs[task.id].dimension, + task.data.size, include_outside=False, use_server_track_ids=self._use_server_track_ids ), diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index d7d16f8cba33..43df503b4d84 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -3,11 +3,13 @@ # # SPDX-License-Identifier: MIT +import itertools import os from collections import OrderedDict from copy import deepcopy from enum import Enum from tempfile import TemporaryDirectory +from typing import Container, Optional from datumaro.components.errors import DatasetError, DatasetImportError, DatasetNotFoundError from django.db import transaction @@ -25,7 +27,9 @@ from cvat.apps.dataset_manager.annotation import AnnotationIR, AnnotationManager from cvat.apps.dataset_manager.bindings import TaskData, JobData, CvatImportError, CvatDatasetNotFoundError from cvat.apps.dataset_manager.formats.registry import make_exporter, make_importer -from cvat.apps.dataset_manager.util import add_prefetch_fields, bulk_create, get_cached +from cvat.apps.dataset_manager.util import ( + add_prefetch_fields, bulk_create, get_cached, faster_deepcopy +) dlogger = DatasetLogManager() @@ -763,15 +767,27 @@ def import_annotations(self, src_file, importer, **options): self.create(job_data.data.slice(self.start_frame, self.stop_frame).serialize()) class TaskAnnotation: + # For GT pool-enabled tasks, we: + # - copy GT job annotations into validation frames in normal jobs on task export + # - copy GT annotations into validation frames in normal and GT jobs on task import + def __init__(self, pk): self.db_task = models.Task.objects.prefetch_related( Prefetch('data__images', queryset=models.Image.objects.order_by('frame')) ).get(id=pk) - # Postgres doesn't guarantee an order by default without explicit order_by - self.db_jobs = models.Job.objects.select_related("segment").filter( - segment__task_id=pk, type=models.JobType.ANNOTATION.value, - ).order_by('id') + requested_job_types = [models.JobType.ANNOTATION] + if hasattr(self.db_task.data, 'validation_layout') and ( + self.db_task.data.validation_layout.mode == models.ValidationMode.GT_POOL + ): + requested_job_types.append(models.JobType.GROUND_TRUTH) + + self.db_jobs = ( + models.Job.objects + .select_related("segment") + .filter(segment__task_id=pk, type__in=requested_job_types) + ) + self.ir_data = AnnotationIR(self.db_task.dimension) def reset(self): @@ -794,13 +810,21 @@ def _patch_data(self, data, action): _data.data = put_job_data(jid, job_data) else: _data.data = patch_job_data(jid, job_data, action) + if _data.version > self.ir_data.version: self.ir_data.version = _data.version - self._merge_data(_data, jobs[jid]["start"], self.db_task.overlap, self.db_task.dimension) - def _merge_data(self, data, start_frame, overlap, dimension): - annotation_manager = AnnotationManager(self.ir_data) - annotation_manager.merge(data, start_frame, overlap, dimension) + self._merge_data(_data, jobs[jid]["start"]) + + def _merge_data( + self, data, start_frame: int, *, override_frames: Optional[Container[int]] = None + ): + annotation_manager = AnnotationManager(self.ir_data, dimension=self.db_task.dimension) + + if override_frames: + annotation_manager.clear_frames(override_frames) + + annotation_manager.merge(data, start_frame, overlap=self.db_task.overlap) def put(self, data): self._patch_data(data, None) @@ -821,19 +845,62 @@ def delete(self, data=None): def init_from_db(self): self.reset() + gt_job = None for db_job in self.db_jobs: - if db_job.type != models.JobType.ANNOTATION: + if db_job.type == models.JobType.GROUND_TRUTH: + gt_job = db_job continue - annotation = JobAnnotation(db_job.id, is_prefetched=True) - annotation.init_from_db() - if annotation.ir_data.version > self.ir_data.version: - self.ir_data.version = annotation.ir_data.version - db_segment = db_job.segment - start_frame = db_segment.start_frame - overlap = self.db_task.overlap - dimension = self.db_task.dimension - self._merge_data(annotation.ir_data, start_frame, overlap, dimension) + gt_annotation = JobAnnotation(db_job.id, is_prefetched=True) + gt_annotation.init_from_db() + if gt_annotation.ir_data.version > self.ir_data.version: + self.ir_data.version = gt_annotation.ir_data.version + + self._merge_data(gt_annotation.ir_data, start_frame=db_job.segment.start_frame) + + if ( + hasattr(self.db_task.data, 'validation_layout') and + self.db_task.data.validation_layout.mode == models.ValidationMode.GT_POOL + ): + self._init_gt_pool_annotations(gt_job) + + def _init_gt_pool_annotations(self, gt_job: models.Job): + # Copy GT pool annotations into normal jobs + gt_pool_frames = gt_job.segment.frame_set + task_validation_frame_groups: dict[int, int] = {} # real_id -> [placeholder_id, ...] + task_validation_frame_ids: set[int] = set() + for frame_id, real_frame_id in ( + self.db_task.data.images + .filter(is_placeholder=True, real_frame_id__in=gt_pool_frames) + .values_list('frame', 'real_frame_id') + .iterator(chunk_size=1000) + ): + task_validation_frame_ids.add(frame_id) + task_validation_frame_groups.setdefault(real_frame_id, []).append(frame_id) + + gt_annotations = JobAnnotation(gt_job.id, is_prefetched=True) + gt_annotations.init_from_db() + if gt_annotations.ir_data.version > self.ir_data.version: + self.ir_data.version = gt_annotations.ir_data.version + + task_annotation_manager = AnnotationManager(self.ir_data, dimension=self.db_task.dimension) + task_annotation_manager.clear_frames(task_validation_frame_ids) + + for ann_type, gt_annotation in itertools.chain( + zip(itertools.repeat('tag'), gt_annotations.ir_data.tags), + zip(itertools.repeat('shape'), gt_annotations.ir_data.shapes), + ): + for placeholder_frame_id in task_validation_frame_groups[gt_annotation["frame"]]: + gt_annotation = faster_deepcopy(gt_annotation) + gt_annotation["frame"] = placeholder_frame_id + + if ann_type == 'tag': + self.ir_data.add_tag(gt_annotation) + elif ann_type == 'shape': + self.ir_data.add_shape(gt_annotation) + else: + # It's only supported for tags and shapes + assert False def export(self, dst_file, exporter, host='', **options): task_data = TaskData( diff --git a/cvat/apps/dataset_manager/util.py b/cvat/apps/dataset_manager/util.py index b5ed83ed58b1..e73c651416fa 100644 --- a/cvat/apps/dataset_manager/util.py +++ b/cvat/apps/dataset_manager/util.py @@ -81,13 +81,15 @@ def get_cached(queryset: models.QuerySet, pk: int) -> models.Model: return result -def deepcopy_simple(v): - # Default deepcopy is very slow - - if isinstance(v, dict): - return {k: deepcopy_simple(vv) for k, vv in v.items()} - elif isinstance(v, (list, tuple, set)): - return type(v)(deepcopy_simple(vv) for vv in v) +def faster_deepcopy(v): + "A slightly optimized version of the default deepcopy, can be used as a drop-in replacement." + # Default deepcopy is very slow, here we do shallow copy for primitive types and containers + + t = type(v) + if t is dict: + return {k: faster_deepcopy(vv) for k, vv in v.items()} + elif t in (list, tuple, set): + return type(v)(faster_deepcopy(vv) for vv in v) elif isinstance(v, (int, float, str, bool)) or v is None: return v else: From acc06976c43323cd6c83a6e1c138a481ffa17b13 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 4 Sep 2024 15:32:54 +0300 Subject: [PATCH 117/227] Support import and export in tasks with gt pool --- cvat/apps/dataset_manager/bindings.py | 48 ++++++-- cvat/apps/dataset_manager/task.py | 151 +++++++++++++++----------- 2 files changed, 126 insertions(+), 73 deletions(-) diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index ddd89df2b294..dd633646a2ed 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -13,7 +13,7 @@ from operator import add from pathlib import Path from types import SimpleNamespace -from typing import (Any, Callable, DefaultDict, Dict, Iterable, List, Literal, Mapping, +from typing import (Any, Callable, DefaultDict, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, Optional, OrderedDict, Sequence, Set, Tuple, Union) from attrs.converters import to_bool @@ -31,6 +31,7 @@ from cvat.apps.dataset_manager.formats.utils import get_label_color from cvat.apps.dataset_manager.util import add_prefetch_fields +from cvat.apps.engine import models from cvat.apps.engine.frame_provider import TaskFrameProvider, FrameQuality, FrameOutputType from cvat.apps.engine.models import (AttributeSpec, AttributeType, DimensionType, Job, JobType, Label, LabelType, Project, SegmentType, ShapeType, @@ -204,10 +205,10 @@ class CommonData(InstanceLabelData): Label = namedtuple('Label', 'id, name, color, type') def __init__(self, - annotation_ir, - db_task, + annotation_ir: AnnotationIR, + db_task: Task, *, - host='', + host: str = '', create_callback=None, use_server_track_ids: bool = False, included_frames: Optional[Sequence[int]] = None @@ -220,7 +221,7 @@ def __init__(self, self._frame_info = {} self._frame_mapping: Dict[str, int] = {} self._frame_step = db_task.data.get_frame_step() - self._db_data = db_task.data + self._db_data: models.Data = db_task.data self._use_server_track_ids = use_server_track_ids self._required_frames = included_frames self._db_subset = db_task.subset @@ -242,7 +243,7 @@ def start(self) -> int: def stop(self) -> int: return max(0, len(self) - 1) - def _get_queryset(self): + def _get_db_images(self) -> Iterator[models.Image]: raise NotImplementedError() def abs_frame_id(self, relative_id): @@ -273,7 +274,6 @@ def _init_frame_info(self): } for frame in self.rel_range } else: - queryset = self._get_queryset() self._frame_info = { self.rel_frame_id(db_image.frame): { "id": db_image.id, @@ -281,7 +281,7 @@ def _init_frame_info(self): "width": db_image.width, "height": db_image.height, "subset": self._db_subset, - } for db_image in queryset + } for db_image in self._get_db_images() } self._frame_mapping = { @@ -671,7 +671,7 @@ def match_frame_fuzzy(self, path: str, *, path_has_ext: bool = True) -> Optional class JobData(CommonData): META_FIELD = "job" - def __init__(self, annotation_ir, db_job, **kwargs): + def __init__(self, annotation_ir: AnnotationIR, db_job: Job, **kwargs): self._db_job = db_job self._db_task = db_job.segment.task @@ -742,7 +742,7 @@ def __len__(self): segment = self._db_job.segment return segment.stop_frame - segment.start_frame + 1 - def _get_queryset(self): + def _get_db_images(self): return (image for image in self._db_data.images.all() if image.frame in self.abs_range) @property @@ -775,7 +775,7 @@ def db_instance(self): class TaskData(CommonData): META_FIELD = "task" - def __init__(self, annotation_ir, db_task, **kwargs): + def __init__(self, annotation_ir: AnnotationIR, db_task: Task, **kwargs): self._db_task = db_task super().__init__(annotation_ir, db_task, **kwargs) @@ -851,9 +851,33 @@ def rel_range(self): def db_instance(self): return self._db_task - def _get_queryset(self): + def _get_db_images(self): return self._db_data.images.all() + def _init_frame_info(self): + super()._init_frame_info() + + if hasattr(self.db_data, 'validation_layout') and ( + self.db_data.validation_layout.mode == models.ValidationMode.GT_POOL + ): + # For GT pool-enabled tasks, we: + # - skip validation frames in normal jobs on annotation export + # - load annotations for GT pool frames on annotation import + + assert not hasattr(self.db_data, 'video') + + for db_image in self._get_db_images(): + # We should not include placeholder frames in task export, so we exclude them + if db_image.is_placeholder: + self._excluded_frames.add(db_image.frame) + continue + + # We should not match placeholder frames during task import, + # so we update the frame matching index + self._frame_mapping[self._get_filename(db_image.path)] = ( + self.rel_frame_id(db_image.frame) + ) + class ProjectData(InstanceLabelData): META_FIELD = 'project' @attrs diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 43df503b4d84..7c84cad3df2a 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -9,7 +9,7 @@ from copy import deepcopy from enum import Enum from tempfile import TemporaryDirectory -from typing import Container, Optional +from typing import Optional, Union from datumaro.components.errors import DatasetError, DatasetImportError, DatasetNotFoundError from django.db import transaction @@ -416,6 +416,8 @@ def _create(self, data): self._save_tracks_to_db(data["tracks"]) def create(self, data): + data = self._validate_input_annotations(data) + self._create(data) handle_annotations_change(self.db_job, self.data, "create") @@ -423,6 +425,8 @@ def create(self, data): self._set_updated_date() def put(self, data): + data = self._validate_input_annotations(data) + deleted_data = self._delete() handle_annotations_change(self.db_job, deleted_data, "delete") @@ -435,6 +439,8 @@ def put(self, data): self._set_updated_date() def update(self, data): + data = self._validate_input_annotations(data) + self._delete(data) self._create(data) handle_annotations_change(self.db_job, self.data, "update") @@ -442,6 +448,22 @@ def update(self, data): if not self._data_is_empty(self.data): self._set_updated_date() + def _validate_input_annotations(self, data: Union[AnnotationIR, dict]) -> AnnotationIR: + if not isinstance(data, AnnotationIR): + data = AnnotationIR(self.db_job.segment.task.dimension, data) + + db_data = self.db_job.segment.task.data + + if data.tracks and hasattr(db_data, 'validation_layout') and ( + db_data.validation_layout.mode == models.ValidationMode.GT_POOL + ): + # Only tags and shapes can be used in tasks with GT pool + raise ValidationError("Tracks are not supported when task validation mode is {}".format( + models.ValidationMode.GT_POOL + )) + + return data + def _delete_job_labeledimages(self, ids__UNSAFE: list[int]) -> None: # ids__UNSAFE is a list, received from the user # we MUST filter it by job_id additionally before applying to any queries @@ -767,10 +789,6 @@ def import_annotations(self, src_file, importer, **options): self.create(job_data.data.slice(self.start_frame, self.stop_frame).serialize()) class TaskAnnotation: - # For GT pool-enabled tasks, we: - # - copy GT job annotations into validation frames in normal jobs on task export - # - copy GT annotations into validation frames in normal and GT jobs on task import - def __init__(self, pk): self.db_task = models.Task.objects.prefetch_related( Prefetch('data__images', queryset=models.Image.objects.order_by('frame')) @@ -793,8 +811,16 @@ def __init__(self, pk): def reset(self): self.ir_data.reset() - def _patch_data(self, data, action): - _data = data if isinstance(data, AnnotationIR) else AnnotationIR(self.db_task.dimension, data) + def _patch_data(self, data: Union[AnnotationIR, dict], action: Optional[PatchAction]): + if not isinstance(data, AnnotationIR): + data = AnnotationIR(self.db_task.dimension, data) + + if action != PatchAction.DELETE and ( + hasattr(self.db_task.data, 'validation_layout') and + self.db_task.data.validation_layout.mode == models.ValidationMode.GT_POOL + ): + self._preprocess_input_annotations_for_gt_pool_task(data) + splitted_data = {} jobs = {} for db_job in self.db_jobs: @@ -802,28 +828,22 @@ def _patch_data(self, data, action): 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) for jid, job_data in splitted_data.items(): - _data = AnnotationIR(self.db_task.dimension) + 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) else: - _data.data = patch_job_data(jid, job_data, action) + data.data = patch_job_data(jid, job_data, action) - if _data.version > self.ir_data.version: - self.ir_data.version = _data.version + if data.version > self.ir_data.version: + self.ir_data.version = data.version - self._merge_data(_data, jobs[jid]["start"]) + self._merge_data(data, jobs[jid]["start"]) - def _merge_data( - self, data, start_frame: int, *, override_frames: Optional[Container[int]] = None - ): + def _merge_data(self, data: AnnotationIR, start_frame: int): annotation_manager = AnnotationManager(self.ir_data, dimension=self.db_task.dimension) - - if override_frames: - annotation_manager.clear_frames(override_frames) - annotation_manager.merge(data, start_frame, overlap=self.db_task.overlap) def put(self, data): @@ -832,39 +852,22 @@ def put(self, data): def create(self, data): self._patch_data(data, PatchAction.CREATE) - def update(self, data): - self._patch_data(data, PatchAction.UPDATE) - - def delete(self, data=None): - if data: - self._patch_data(data, PatchAction.DELETE) - else: - for db_job in self.db_jobs: - delete_job_data(db_job.id) + def _preprocess_input_annotations_for_gt_pool_task( + self, data: Union[AnnotationIR, dict] + ) -> AnnotationIR: + if not isinstance(data, AnnotationIR): + data = AnnotationIR(self.db_task.dimension, data) - def init_from_db(self): - self.reset() - - gt_job = None - for db_job in self.db_jobs: - if db_job.type == models.JobType.GROUND_TRUTH: - gt_job = db_job - continue + if data.tracks: + # Only tags and shapes are supported in tasks with GT pool + raise ValidationError("Tracks are not supported when task validation mode is {}".format( + models.ValidationMode.GT_POOL + )) - gt_annotation = JobAnnotation(db_job.id, is_prefetched=True) - gt_annotation.init_from_db() - if gt_annotation.ir_data.version > self.ir_data.version: - self.ir_data.version = gt_annotation.ir_data.version - - self._merge_data(gt_annotation.ir_data, start_frame=db_job.segment.start_frame) - - if ( - hasattr(self.db_task.data, 'validation_layout') and - self.db_task.data.validation_layout.mode == models.ValidationMode.GT_POOL - ): - self._init_gt_pool_annotations(gt_job) + gt_job = next( + db_job for db_job in self.db_jobs if db_job.type == models.JobType.GROUND_TRUTH + ) - def _init_gt_pool_annotations(self, gt_job: models.Job): # Copy GT pool annotations into normal jobs gt_pool_frames = gt_job.segment.frame_set task_validation_frame_groups: dict[int, int] = {} # real_id -> [placeholder_id, ...] @@ -878,30 +881,56 @@ def _init_gt_pool_annotations(self, gt_job: models.Job): task_validation_frame_ids.add(frame_id) task_validation_frame_groups.setdefault(real_frame_id, []).append(frame_id) - gt_annotations = JobAnnotation(gt_job.id, is_prefetched=True) - gt_annotations.init_from_db() - if gt_annotations.ir_data.version > self.ir_data.version: - self.ir_data.version = gt_annotations.ir_data.version + assert sorted(gt_pool_frames) == list(range(min(gt_pool_frames), max(gt_pool_frames) + 1)) + gt_annotations = data.slice(min(gt_pool_frames), max(gt_pool_frames)) - task_annotation_manager = AnnotationManager(self.ir_data, dimension=self.db_task.dimension) + task_annotation_manager = AnnotationManager(data, dimension=self.db_task.dimension) task_annotation_manager.clear_frames(task_validation_frame_ids) for ann_type, gt_annotation in itertools.chain( - zip(itertools.repeat('tag'), gt_annotations.ir_data.tags), - zip(itertools.repeat('shape'), gt_annotations.ir_data.shapes), + zip(itertools.repeat('tag'), gt_annotations.tags), + zip(itertools.repeat('shape'), gt_annotations.shapes), ): for placeholder_frame_id in task_validation_frame_groups[gt_annotation["frame"]]: gt_annotation = faster_deepcopy(gt_annotation) gt_annotation["frame"] = placeholder_frame_id if ann_type == 'tag': - self.ir_data.add_tag(gt_annotation) + data.add_tag(gt_annotation) elif ann_type == 'shape': - self.ir_data.add_shape(gt_annotation) + data.add_shape(gt_annotation) else: - # It's only supported for tags and shapes assert False + return data + + def update(self, data): + self._patch_data(data, PatchAction.UPDATE) + + def delete(self, data=None): + if data: + self._patch_data(data, PatchAction.DELETE) + else: + for db_job in self.db_jobs: + delete_job_data(db_job.id) + + def init_from_db(self): + self.reset() + + for db_job in self.db_jobs: + if db_job.type == models.JobType.GROUND_TRUTH and not ( + hasattr(self.db_task.data, 'validation_layout') and + self.db_task.data.validation_layout.mode == models.ValidationMode.GT_POOL + ): + continue + + gt_annotation = JobAnnotation(db_job.id, is_prefetched=True) + gt_annotation.init_from_db() + if gt_annotation.ir_data.version > self.ir_data.version: + self.ir_data.version = gt_annotation.ir_data.version + + self._merge_data(gt_annotation.ir_data, start_frame=db_job.segment.start_frame) + def export(self, dst_file, exporter, host='', **options): task_data = TaskData( annotation_ir=self.ir_data, From 876164e82cc47beb169ee200e9c23a17e74857d7 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 4 Sep 2024 18:54:21 +0300 Subject: [PATCH 118/227] Support honeypot updates in jobs --- cvat/apps/engine/views.py | 139 ++++++++++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 30 deletions(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 8bdec141400e..f43d45ec1143 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -11,13 +11,13 @@ import functools import random -from contextlib import suppress +from contextlib import closing, suppress from PIL import Image from types import SimpleNamespace from typing import Optional, Any, Dict, List, Union, cast, Callable, Mapping, Iterable import traceback import textwrap -from collections import namedtuple +from collections import Counter, namedtuple from copy import copy from datetime import datetime from redis.exceptions import ConnectionError as RedisConnectionError @@ -55,6 +55,9 @@ from rq.job import Job as RQJob, JobStatus as RQJobStatus import cvat.apps.dataset_manager as dm +from cvat.apps.dataset_manager.annotation import AnnotationIR, AnnotationManager +from cvat.apps.dataset_manager.task import JobAnnotation +from cvat.apps.dataset_manager.util import faster_deepcopy import cvat.apps.dataset_manager.views # pylint: disable=unused-import from cvat.apps.engine.cloud_provider import db_storage_to_storage_instance, import_resource_from_cloud_storage from cvat.apps.events.handlers import handle_dataset_import @@ -106,7 +109,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, prepare_chunk from cvat.apps.engine.permissions import (CloudStoragePermission, CommentPermission, IssuePermission, JobPermission, LabelPermission, ProjectPermission, TaskPermission, UserPermission) @@ -2130,6 +2133,7 @@ def preview(self, request, pk): '200': OpenApiResponse(JobHoneypotReadSerializer), }) @action(detail=True, methods=["GET", "PATCH"], url_path='honeypot') + @transaction.atomic def honeypot(self, request, pk): self.get_object() # call check_object_permissions as well @@ -2147,17 +2151,18 @@ def honeypot(self, request, pk): ) ).get(pk=pk) - if ( - not hasattr(db_job.segment.task.data, 'validation_layout') or - db_job.segment.task.data.validation_layout.mode != models.ValidationMode.GT_POOL + if not ( + hasattr(db_job.segment.task.data, 'validation_layout') and + db_job.segment.task.data.validation_layout.mode == models.ValidationMode.GT_POOL ): raise ValidationError("Honeypots are not configured in the task") db_segment = db_job.segment - task_all_honeypots = set(db_segment.task.gt_job.segment.frame_set) + db_task = db_segment.task + task_all_honeypots = set(db_task.gt_job.segment.frame_set) db_task_frames: dict[int, models.Image] = { - frame.frame: frame for frame in db_segment.task.data.images.all() + frame.frame: frame for frame in db_task.data.images.all() } task_placeholder_frames = set( frame_id for frame_id, frame in db_task_frames.items() @@ -2170,7 +2175,7 @@ def honeypot(self, request, pk): request_serializer.is_valid(raise_exception=True) input_data = request_serializer.validated_data - deleted_task_frames = db_segment.task.data.deleted_frames + deleted_task_frames = db_task.data.deleted_frames task_active_honeypots = task_all_honeypots.difference(deleted_task_frames) segment_honeypots_count = len(segment_honeypots) @@ -2179,12 +2184,13 @@ def honeypot(self, request, pk): if frame_selection_method == models.JobFrameSelectionMethod.MANUAL: task_honeypot_frame_map: dict[str, int] = { v.path: k for k, v in db_task_frames.items() - } + } # frame_name -> id requested_frame_names: list[str] = input_data['frames'] requested_frame_ids: list[int] = [] requested_unknown_frames: list[str] = [] requested_inactive_frames: list[str] = [] + requested_normal_frames: list[str] = [] for requested_frame_name in requested_frame_names: requested_frame_id = task_honeypot_frame_map.get(requested_frame_name) @@ -2192,16 +2198,15 @@ def honeypot(self, request, pk): requested_unknown_frames.append(requested_frame_name) continue + if requested_frame_id not in task_all_honeypots: + requested_normal_frames.append(requested_frame_name) + continue + if requested_frame_id not in task_active_honeypots: requested_inactive_frames.append(requested_frame_name) continue - requested_frame_ids.append(task_honeypot_frame_map) - - if len(set(requested_frame_ids)) != len(requested_frame_names): - raise ValidationError( - "Could not update honeypot frames: validation frames cannot repeat" - ) + requested_frame_ids.append(requested_frame_id) if requested_unknown_frames: raise ValidationError( @@ -2211,6 +2216,14 @@ def honeypot(self, request, pk): ) ) + if requested_normal_frames: + raise ValidationError( + "Could not update honeypot frames: " + "frames {} are not in the honeypot pool. ".format( + format_list(requested_normal_frames) + ) + ) + if requested_inactive_frames: raise ValidationError( "Could not update honeypot frames: frames {} are removed. " @@ -2228,14 +2241,28 @@ def honeypot(self, request, pk): ) ) + if len(set(requested_frame_ids)) != len(requested_frame_names): + repeated_frames = tuple( + frame_name + for frame_name, repeats in Counter(requested_frame_names).most_common() + if 1 < repeats + ) + raise ValidationError( + "Could not update honeypot frames: validation frames cannot repeat. " + "Repeated frames: {}".format(format_list(repeated_frames)) + ) + elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: - # TODO: take current validation frames distribution into account - # simply using random here will break uniformity + # TODO: take the current validation frames distribution into account, + # simply using random here can break distribution uniformness requested_frame_ids = random.sample( task_active_honeypots, k=segment_honeypots_count ) + else: + assert False # Replace validation frames in the job + old_honeypot_real_ids = [] updated_db_frames = [] new_validation_frame_iter = iter(requested_frame_ids) for current_frame_id in db_segment.frame_set: @@ -2245,31 +2272,83 @@ def honeypot(self, request, pk): db_segment_frame = db_task_frames[current_frame_id] assert db_segment_frame.is_placeholder + old_honeypot_real_ids.append(db_segment_frame.real_frame_id) + # Change image in the current segment frame db_segment_frame.path = db_requested_frame.path db_segment_frame.width = db_requested_frame.width db_segment_frame.height = db_requested_frame.height + db_segment_frame.real_frame_id = db_requested_frame.frame updated_db_frames.append(db_segment_frame) assert next(new_validation_frame_iter, None) is None - models.Image.objects.bulk_update(updated_db_frames, fields=['path', 'width', 'height']) + models.Image.objects.bulk_update( + updated_db_frames, fields=['path', 'width', 'height', 'real_frame_id'] + ) db_segment.save() - frame_provider = JobFrameProvider(db_job) - updated_segment_chunk_ids = set( - frame_provider.get_chunk_number(updated_segment_frame_id) - for updated_segment_frame_id in requested_frame_ids - ) + updated_validation_frames = [ + segment_frame_id + for new_honeypot_id, old_honeypot_id, segment_frame_id in zip( + requested_frame_ids, old_honeypot_real_ids, segment_honeypots + ) + if new_honeypot_id != old_honeypot_id + ] + if updated_validation_frames: + # Remove annotations on changed validation frames + job_annotation = JobAnnotation(db_job.id) + job_annotation.init_from_db() + job_annotation_manager = AnnotationManager( + job_annotation.ir_data, dimension=db_task.dimension + ) + job_annotation_manager.clear_frames( + set(db_segment.frame_set).difference(updated_validation_frames) + ) + 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) + for updated_segment_frame_id in requested_frame_ids + ) + segment_frames = sorted(db_segment.frame_set) + + media_cache = MediaCache() + for chunk_id in sorted(updated_segment_chunk_ids): + chunk_frames = segment_frames[ + chunk_id * db_task.segment_size : + (chunk_id + 1) * db_task.segment_size + ] + + for quality in FrameQuality.__members__.values(): + media_cache.remove_segment_chunk(db_segment, chunk_id, quality=quality) + + if db_task.data.storage_method != models.StorageMethodChoice.FILE_SYSTEM: + continue + + # Write updated chunks + def _iterate_chunk_frames(): + for chunk_frame in chunk_frames: + yield task_frame_provider.get_frame(chunk_frame, quality=quality)[0] + + 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_task.data.get_compressed_segment_chunk_path, + FrameQuality.ORIGINAL: db_task.data.get_original_segment_chunk_path, + }[quality] - # TODO: replace specific files directly in chunks - media_cache = MediaCache() - for chunk_id in updated_segment_chunk_ids: - for quality in FrameQuality.__members__.values(): - media_cache.remove_segment_chunk(db_segment, chunk_id, quality=quality) + with open(get_chunk_path(chunk_id, db_segment.id), 'bw') as f: + f.write(chunk) - segment_honeypots = set(requested_frame_ids) + segment_honeypots = requested_frame_ids response_serializer = JobHoneypotReadSerializer({'frames': sorted(segment_honeypots)}) return Response(response_serializer.data, status=status.HTTP_200_OK) From 915abdfae5789e1a0dff2fa6686815ba0d1de1c7 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 4 Sep 2024 18:56:00 +0300 Subject: [PATCH 119/227] Refactor some code --- cvat/apps/engine/views.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index f43d45ec1143..81a637bb9216 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -2159,10 +2159,11 @@ def honeypot(self, request, pk): db_segment = db_job.segment db_task = db_segment.task + db_data = db_task.data task_all_honeypots = set(db_task.gt_job.segment.frame_set) db_task_frames: dict[int, models.Image] = { - frame.frame: frame for frame in db_task.data.images.all() + frame.frame: frame for frame in db_data.images.all() } task_placeholder_frames = set( frame_id for frame_id, frame in db_task_frames.items() @@ -2175,7 +2176,7 @@ def honeypot(self, request, pk): request_serializer.is_valid(raise_exception=True) input_data = request_serializer.validated_data - deleted_task_frames = db_task.data.deleted_frames + deleted_task_frames = db_data.deleted_frames task_active_honeypots = task_all_honeypots.difference(deleted_task_frames) segment_honeypots_count = len(segment_honeypots) @@ -2327,7 +2328,7 @@ def honeypot(self, request, pk): for quality in FrameQuality.__members__.values(): media_cache.remove_segment_chunk(db_segment, chunk_id, quality=quality) - if db_task.data.storage_method != models.StorageMethodChoice.FILE_SYSTEM: + if db_data.storage_method != models.StorageMethodChoice.FILE_SYSTEM: continue # Write updated chunks @@ -2341,8 +2342,8 @@ def _iterate_chunk_frames(): ) get_chunk_path = { - FrameQuality.COMPRESSED: db_task.data.get_compressed_segment_chunk_path, - FrameQuality.ORIGINAL: db_task.data.get_original_segment_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), 'bw') as f: From c42f20b523f4778c065c11bdf38fb772ac4b78ff Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 5 Sep 2024 16:07:29 +0300 Subject: [PATCH 120/227] Split the honeypot updating part --- cvat/apps/engine/serializers.py | 25 ---- cvat/apps/engine/views.py | 252 +------------------------------- cvat/schema.yml | 78 +--------- 3 files changed, 7 insertions(+), 348 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index e95fa9e81955..e28ec8d4d01f 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -933,31 +933,6 @@ class Meta: fields = ('url', 'id', 'assignee', 'status', 'stage', 'state', 'type') read_only_fields = fields -class JobHoneypotWriteSerializer(serializers.Serializer): - frame_selection_method = serializers.ChoiceField( - choices=models.JobFrameSelectionMethod.choices(), required=True - ) - frames = serializers.ListSerializer( - child=serializers.CharField(max_length=MAX_FILENAME_LENGTH), - default=[], required=False, allow_null=True - ) - - def validate(self, attrs): - frame_selection_method = attrs["frame_selection_method"] - if frame_selection_method == models.JobFrameSelectionMethod.MANUAL: - required_field_name = "frames" - if required_field_name not in attrs: - raise serializers.ValidationError("'{}' must be set".format(required_field_name)) - elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: - pass - else: - assert False - - return super().validate(attrs) - -class JobHoneypotReadSerializer(serializers.Serializer): - frames = serializers.ListSerializer(child=serializers.IntegerField(), allow_empty=True) - class SegmentSerializer(serializers.ModelSerializer): jobs = SimpleJobSerializer(many=True, source='job_set') frames = serializers.ListSerializer(child=serializers.IntegerField(), allow_empty=True) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 81a637bb9216..290d45cfe30f 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -9,20 +9,18 @@ import re import shutil import functools -import random -from contextlib import closing, suppress +from contextlib import suppress from PIL import Image from types import SimpleNamespace from typing import Optional, Any, Dict, List, Union, cast, Callable, Mapping, Iterable import traceback import textwrap -from collections import Counter, namedtuple +from collections import namedtuple from copy import copy from datetime import datetime from redis.exceptions import ConnectionError as RedisConnectionError from tempfile import NamedTemporaryFile -from textwrap import dedent import django_rq from attr.converters import to_bool @@ -55,9 +53,6 @@ from rq.job import Job as RQJob, JobStatus as RQJobStatus import cvat.apps.dataset_manager as dm -from cvat.apps.dataset_manager.annotation import AnnotationIR, AnnotationManager -from cvat.apps.dataset_manager.task import JobAnnotation -from cvat.apps.dataset_manager.util import faster_deepcopy import cvat.apps.dataset_manager.views # pylint: disable=unused-import from cvat.apps.engine.cloud_provider import db_storage_to_storage_instance, import_resource_from_cloud_storage from cvat.apps.events.handlers import handle_dataset_import @@ -78,7 +73,7 @@ from cvat.apps.engine.serializers import ( AboutSerializer, AnnotationFileSerializer, BasicUserSerializer, DataMetaReadSerializer, DataMetaWriteSerializer, DataSerializer, - FileInfoSerializer, JobHoneypotReadSerializer, JobHoneypotWriteSerializer, JobReadSerializer, JobWriteSerializer, LabelSerializer, + FileInfoSerializer, JobReadSerializer, JobWriteSerializer, LabelSerializer, LabeledDataSerializer, ProjectReadSerializer, ProjectWriteSerializer, RqStatusSerializer, TaskReadSerializer, TaskWriteSerializer, @@ -94,7 +89,7 @@ from utils.dataset_manifest import ImageManifestManager from cvat.apps.engine.utils import ( - av_scan_paths, format_list, process_failed_job, + av_scan_paths, process_failed_job, parse_exception_message, get_rq_job_meta, import_resource_with_clean_up_after, sendfile, define_dependent_job, get_rq_lock_by_user, ) @@ -109,7 +104,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, prepare_chunk +from cvat.apps.engine.cache import MediaCache from cvat.apps.engine.permissions import (CloudStoragePermission, CommentPermission, IssuePermission, JobPermission, LabelPermission, ProjectPermission, TaskPermission, UserPermission) @@ -813,7 +808,7 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, 'subset', 'mode', 'dimension', 'tracker_link' ) filter_fields = list(search_fields) + ['id', 'project_id', 'updated_date'] - filter_description = dedent(""" + filter_description = textwrap.dedent(""" There are few examples for complex filtering tasks:\n - Get all tasks from 1,2,3 projects - { "and" : [{ "in" : [{ "var" : "project_id" }, [1, 2, 3]]}]}\n @@ -2119,241 +2114,6 @@ def preview(self, request, pk): ) return data_getter() - @extend_schema( - methods=["GET"], - summary="Allows to get current honeypot frames", - responses={ - '200': OpenApiResponse(JobHoneypotReadSerializer), - }) - @extend_schema( - methods=["PATCH"], - summary="Allows to update current honeypot frames", - request=JobHoneypotWriteSerializer, - responses={ - '200': OpenApiResponse(JobHoneypotReadSerializer), - }) - @action(detail=True, methods=["GET", "PATCH"], url_path='honeypot') - @transaction.atomic - def honeypot(self, request, pk): - self.get_object() # call check_object_permissions as well - - db_job = models.Job.objects.prefetch_related( - 'segment', - 'segment__task', - Prefetch('segment__task__data', - queryset=( - models.Data.objects - .select_related('video', 'validation_layout') - .prefetch_related( - Prefetch('images', queryset=models.Image.objects.order_by('frame')) - ) - ) - ) - ).get(pk=pk) - - if not ( - hasattr(db_job.segment.task.data, 'validation_layout') and - db_job.segment.task.data.validation_layout.mode == models.ValidationMode.GT_POOL - ): - raise ValidationError("Honeypots are not configured in the task") - - db_segment = db_job.segment - db_task = db_segment.task - db_data = db_task.data - task_all_honeypots = set(db_task.gt_job.segment.frame_set) - - db_task_frames: dict[int, models.Image] = { - frame.frame: frame for frame in db_data.images.all() - } - task_placeholder_frames = set( - frame_id for frame_id, frame in db_task_frames.items() - if frame.is_placeholder - ) - segment_honeypots = set(db_segment.frame_set) & task_placeholder_frames - - if request.method == "PATCH": - request_serializer = JobHoneypotWriteSerializer(data=request.data) - request_serializer.is_valid(raise_exception=True) - input_data = request_serializer.validated_data - - deleted_task_frames = db_data.deleted_frames - task_active_honeypots = task_all_honeypots.difference(deleted_task_frames) - - segment_honeypots_count = len(segment_honeypots) - - frame_selection_method = input_data['frame_selection_method'] - if frame_selection_method == models.JobFrameSelectionMethod.MANUAL: - task_honeypot_frame_map: dict[str, int] = { - v.path: k for k, v in db_task_frames.items() - } # frame_name -> id - - requested_frame_names: list[str] = input_data['frames'] - requested_frame_ids: list[int] = [] - requested_unknown_frames: list[str] = [] - requested_inactive_frames: list[str] = [] - requested_normal_frames: list[str] = [] - for requested_frame_name in requested_frame_names: - requested_frame_id = task_honeypot_frame_map.get(requested_frame_name) - - if requested_frame_id is None: - requested_unknown_frames.append(requested_frame_name) - continue - - if requested_frame_id not in task_all_honeypots: - requested_normal_frames.append(requested_frame_name) - continue - - if requested_frame_id not in task_active_honeypots: - requested_inactive_frames.append(requested_frame_name) - continue - - requested_frame_ids.append(requested_frame_id) - - if requested_unknown_frames: - raise ValidationError( - "Could not update honeypot frames: " - "frames {} do not exist in the task".format( - format_list(requested_unknown_frames) - ) - ) - - if requested_normal_frames: - raise ValidationError( - "Could not update honeypot frames: " - "frames {} are not in the honeypot pool. ".format( - format_list(requested_normal_frames) - ) - ) - - if requested_inactive_frames: - raise ValidationError( - "Could not update honeypot frames: frames {} are removed. " - "Restore them in the honeypot pool first.".format( - format_list(requested_inactive_frames) - ) - ) - - if len(requested_frame_names) != segment_honeypots_count: - raise ValidationError( - "Could not update honeypot frames: " - "the requested number of validation frames must be remain the same." - "Requested {}, current {}".format( - len(requested_frame_names), segment_honeypots_count - ) - ) - - if len(set(requested_frame_ids)) != len(requested_frame_names): - repeated_frames = tuple( - frame_name - for frame_name, repeats in Counter(requested_frame_names).most_common() - if 1 < repeats - ) - raise ValidationError( - "Could not update honeypot frames: validation frames cannot repeat. " - "Repeated frames: {}".format(format_list(repeated_frames)) - ) - - elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: - # TODO: take the current validation frames distribution into account, - # simply using random here can break distribution uniformness - requested_frame_ids = random.sample( - task_active_honeypots, k=segment_honeypots_count - ) - else: - assert False - - # Replace validation frames in the job - old_honeypot_real_ids = [] - updated_db_frames = [] - new_validation_frame_iter = iter(requested_frame_ids) - for current_frame_id in db_segment.frame_set: - if current_frame_id in segment_honeypots: - requested_frame_id = next(new_validation_frame_iter) - db_requested_frame = db_task_frames[requested_frame_id] - db_segment_frame = db_task_frames[current_frame_id] - assert db_segment_frame.is_placeholder - - old_honeypot_real_ids.append(db_segment_frame.real_frame_id) - - # Change image in the current segment frame - db_segment_frame.path = db_requested_frame.path - db_segment_frame.width = db_requested_frame.width - db_segment_frame.height = db_requested_frame.height - db_segment_frame.real_frame_id = db_requested_frame.frame - - updated_db_frames.append(db_segment_frame) - - assert next(new_validation_frame_iter, None) is None - - models.Image.objects.bulk_update( - updated_db_frames, fields=['path', 'width', 'height', 'real_frame_id'] - ) - db_segment.save() - - updated_validation_frames = [ - segment_frame_id - for new_honeypot_id, old_honeypot_id, segment_frame_id in zip( - requested_frame_ids, old_honeypot_real_ids, segment_honeypots - ) - if new_honeypot_id != old_honeypot_id - ] - if updated_validation_frames: - # Remove annotations on changed validation frames - job_annotation = JobAnnotation(db_job.id) - job_annotation.init_from_db() - job_annotation_manager = AnnotationManager( - job_annotation.ir_data, dimension=db_task.dimension - ) - job_annotation_manager.clear_frames( - set(db_segment.frame_set).difference(updated_validation_frames) - ) - 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) - for updated_segment_frame_id in requested_frame_ids - ) - segment_frames = sorted(db_segment.frame_set) - - media_cache = MediaCache() - for chunk_id in sorted(updated_segment_chunk_ids): - chunk_frames = segment_frames[ - chunk_id * db_task.segment_size : - (chunk_id + 1) * db_task.segment_size - ] - - for quality in FrameQuality.__members__.values(): - media_cache.remove_segment_chunk(db_segment, chunk_id, quality=quality) - - if db_data.storage_method != models.StorageMethodChoice.FILE_SYSTEM: - continue - - # Write updated chunks - def _iterate_chunk_frames(): - for chunk_frame in chunk_frames: - yield task_frame_provider.get_frame(chunk_frame, quality=quality)[0] - - 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), 'bw') as f: - f.write(chunk) - - segment_honeypots = requested_frame_ids - - response_serializer = JobHoneypotReadSerializer({'frames': sorted(segment_honeypots)}) - return Response(response_serializer.data, status=status.HTTP_200_OK) - @extend_schema(tags=['issues']) @extend_schema_view( retrieve=extend_schema( diff --git a/cvat/schema.yml b/cvat/schema.yml index 03ca0302c647..76b55c35df83 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -2565,62 +2565,6 @@ paths: description: Format is not available '409': description: Exporting is already in progress - /api/jobs/{id}/honeypot: - get: - operationId: jobs_retrieve_honeypot - summary: Allows to get current honeypot frames - parameters: - - in: path - name: id - schema: - type: integer - description: A unique integer value identifying this job. - required: true - tags: - - jobs - security: - - sessionAuth: [] - csrfAuth: [] - tokenAuth: [] - - signatureAuth: [] - - basicAuth: [] - responses: - '200': - content: - application/vnd.cvat+json: - schema: - $ref: '#/components/schemas/JobHoneypotRead' - description: '' - patch: - operationId: jobs_partial_update_honeypot - summary: Allows to update current honeypot frames - parameters: - - in: path - name: id - schema: - type: integer - description: A unique integer value identifying this job. - required: true - tags: - - jobs - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedJobHoneypotWriteRequest' - security: - - sessionAuth: [] - csrfAuth: [] - tokenAuth: [] - - signatureAuth: [] - - basicAuth: [] - responses: - '200': - content: - application/vnd.cvat+json: - schema: - $ref: '#/components/schemas/JobHoneypotRead' - description: '' /api/jobs/{id}/preview: get: operationId: jobs_retrieve_preview @@ -8062,15 +8006,6 @@ components: oneOf: - $ref: '#/components/schemas/LabeledDataRequest' - $ref: '#/components/schemas/AnnotationFileRequest' - JobHoneypotRead: - type: object - properties: - frames: - type: array - items: - type: integer - required: - - frames JobRead: type: object properties: @@ -9301,18 +9236,6 @@ components: nullable: true resolved: type: boolean - PatchedJobHoneypotWriteRequest: - type: object - properties: - frame_selection_method: - $ref: '#/components/schemas/FrameSelectionMethod' - frames: - type: array - items: - type: string - minLength: 1 - nullable: true - default: [] PatchedJobWriteRequest: type: object properties: @@ -10886,6 +10809,7 @@ components: type: string minLength: 1 maxLength: 1024 + writeOnly: true nullable: true description: | The list of frame ids. Applicable only to the "manual" frame selection method From f67a1a2cd64ea8f0ce9efba3a97f7cf6044afac4 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 5 Sep 2024 16:47:22 +0300 Subject: [PATCH 121/227] Update changelog --- changelog.d/20240812_161617_mzhiltso_job_chunks.md | 12 ++++++++++++ changelog.d/20240812_161734_mzhiltso_job_chunks.md | 4 ---- changelog.d/20240812_161912_mzhiltso_job_chunks.md | 6 ------ 3 files changed, 12 insertions(+), 10 deletions(-) delete mode 100644 changelog.d/20240812_161734_mzhiltso_job_chunks.md delete mode 100644 changelog.d/20240812_161912_mzhiltso_job_chunks.md diff --git a/changelog.d/20240812_161617_mzhiltso_job_chunks.md b/changelog.d/20240812_161617_mzhiltso_job_chunks.md index f78376d94438..b54c5fe9e223 100644 --- a/changelog.d/20240812_161617_mzhiltso_job_chunks.md +++ b/changelog.d/20240812_161617_mzhiltso_job_chunks.md @@ -2,3 +2,15 @@ - A server setting to disable media chunks on the local filesystem () + +### Changed + +- \[Server API\] Chunk ids in each job now start from 0, instead of using ones from the task + () + +### Fixed + +- Various memory leaks in video reading on the server + () +- Job assignees will not receive frames from adjacent jobs in the boundary chunks + () diff --git a/changelog.d/20240812_161734_mzhiltso_job_chunks.md b/changelog.d/20240812_161734_mzhiltso_job_chunks.md deleted file mode 100644 index 2a587593b4f5..000000000000 --- a/changelog.d/20240812_161734_mzhiltso_job_chunks.md +++ /dev/null @@ -1,4 +0,0 @@ -### Changed - -- Jobs now have separate chunk ids starting from 0, instead of using ones from the task - () diff --git a/changelog.d/20240812_161912_mzhiltso_job_chunks.md b/changelog.d/20240812_161912_mzhiltso_job_chunks.md deleted file mode 100644 index 2a0198dd8f78..000000000000 --- a/changelog.d/20240812_161912_mzhiltso_job_chunks.md +++ /dev/null @@ -1,6 +0,0 @@ -### Fixed - -- Various memory leaks in video reading on the server - () -- Job assignees will not receive frames from adjacent jobs in the boundary chunks - () From d72fe85d6f9bf722628c530bc2b21c6dd76c20e0 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 5 Sep 2024 17:22:18 +0300 Subject: [PATCH 122/227] Refactor cache keys in media cache --- cvat/apps/engine/cache.py | 50 +++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 3c1b54e2cd42..ab2c1aacc110 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -100,25 +100,49 @@ def _get(self, key: str) -> Optional[DataWithMime]: return item + def _make_cache_key_prefix( + self, obj: Union[models.Task, models.Segment, models.Job, models.CloudStorage] + ) -> str: + if isinstance(obj, models.Task): + return f"task_{obj.id}" + elif isinstance(obj, models.Segment): + return f"segment_{obj.id}" + elif isinstance(obj, models.Job): + return f"job_{obj.id}" + elif isinstance(obj, models.CloudStorage): + return f"cloudstorage_{obj.id}" + else: + assert False, f"Unexpected object type {type(obj)}" + + def _make_chunk_key( + self, + 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_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" + + def _make_context_image_preview_key(self, db_data: models.Data, frame_number: int) -> str: + return f"context_image_{db_data.id}_{frame_number}_preview" + def get_segment_chunk( self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality ) -> DataWithMime: return self._get_or_set_cache_item( - key=f"segment_{db_segment.id}_{chunk_number}_{quality}", + key=self._make_chunk_key(db_segment, chunk_number, quality=quality), create_callback=lambda: self.prepare_segment_chunk( db_segment, chunk_number, quality=quality ), ) - def _make_task_chunk_key( - self, db_task: models.Task, chunk_number: int, *, quality: FrameQuality - ) -> str: - return f"task_{db_task.id}_{chunk_number}_{quality}" - def get_task_chunk( self, db_task: models.Task, chunk_number: int, *, quality: FrameQuality ) -> Optional[DataWithMime]: - return self._get(key=self._make_task_chunk_key(db_task, chunk_number, quality=quality)) + return self._get(key=self._make_chunk_key(db_task, chunk_number, quality=quality)) def get_or_set_task_chunk( self, @@ -129,7 +153,7 @@ def get_or_set_task_chunk( set_callback: Callable[[], DataWithMime], ) -> DataWithMime: return self._get_or_set_cache_item( - key=self._make_task_chunk_key(db_task, chunk_number, quality=quality), + key=self._make_chunk_key(db_task, chunk_number, quality=quality), create_callback=set_callback, ) @@ -137,7 +161,7 @@ def get_selective_job_chunk( self, db_job: models.Job, chunk_number: int, *, quality: FrameQuality ) -> DataWithMime: return self._get_or_set_cache_item( - key=f"job_{db_job.id}_{chunk_number}_{quality}", + 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 ), @@ -145,22 +169,22 @@ def get_selective_job_chunk( def get_or_set_segment_preview(self, db_segment: models.Segment) -> DataWithMime: return self._get_or_set_cache_item( - f"segment_preview_{db_segment.id}", + self._make_preview_key(db_segment), create_callback=lambda: self._prepare_segment_preview(db_segment), ) def get_cloud_preview(self, db_storage: models.CloudStorage) -> Optional[DataWithMime]: - return self._get(f"cloudstorage_{db_storage.id}_preview") + return self._get(self._make_preview_key(db_storage)) def get_or_set_cloud_preview(self, db_storage: models.CloudStorage) -> DataWithMime: return self._get_or_set_cache_item( - f"cloudstorage_{db_storage.id}_preview", + self._make_preview_key(db_storage), create_callback=lambda: self._prepare_cloud_preview(db_storage), ) def get_frame_context_images(self, db_data: models.Data, frame_number: int) -> DataWithMime: return self._get_or_set_cache_item( - key=f"context_image_{db_data.id}_{frame_number}", + key=self._make_context_image_preview_key(db_data, frame_number), create_callback=lambda: self.prepare_context_images(db_data, frame_number), ) From d5bfb888c44b0e871b08ca7d2a4925327674726a Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 5 Sep 2024 17:23:20 +0300 Subject: [PATCH 123/227] Refactor selective segment chunk creation --- cvat/apps/engine/cache.py | 80 +++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index ab2c1aacc110..c1116d8d88a6 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -335,7 +335,7 @@ def prepare_range_segment_chunk( db_data = db_task.data chunk_size = db_data.chunk_size - chunk_frame_ids = db_segment.frame_set[ + chunk_frame_ids = list(db_segment.frame_set)[ chunk_size * chunk_number : chunk_size * (chunk_number + 1) ] @@ -348,15 +348,14 @@ def prepare_masked_range_segment_chunk( db_task = db_segment.task db_data = db_task.data - from cvat.apps.engine.frame_provider import TaskFrameProvider - - frame_provider = TaskFrameProvider(db_task) - - frame_set = db_segment.frame_set + chunk_size = db_data.chunk_size + chunk_frame_ids = list(db_segment.frame_set)[ + chunk_size * chunk_number : chunk_size * (chunk_number + 1) + ] frame_step = db_data.get_frame_step() - chunk_frames = [] writer = ZipCompressedChunkWriter(db_data.image_quality, dimension=db_task.dimension) + dummy_frame = io.BytesIO() PIL.Image.new("RGB", (1, 1)).save(dummy_frame, writer.IMAGE_EXT) @@ -365,46 +364,47 @@ def prepare_masked_range_segment_chunk( else: frame_size = None - for frame_idx in range(db_data.chunk_size): - frame_idx = ( - db_data.start_frame + chunk_number * db_data.chunk_size + frame_idx * frame_step - ) - if db_data.stop_frame < frame_idx: - break - - frame_bytes = None - - if frame_idx in frame_set: - frame_bytes = frame_provider.get_frame(frame_idx, quality=quality).data - - if frame_size is not None: - # Decoded video frames can have different size, restore the original one + def get_frames(): + with closing( + self._read_raw_frames(db_task, frame_ids=chunk_frame_ids) + ) as read_frame_iter: + for frame_idx in range(db_data.chunk_size): + frame_idx = ( + db_data.start_frame + + chunk_number * db_data.chunk_size + + frame_idx * frame_step + ) + if db_data.stop_frame < frame_idx: + break - frame = PIL.Image.open(frame_bytes) - if frame.size != frame_size: - frame = frame.resize(frame_size) + if frame_idx in chunk_frame_ids: + frame = next(read_frame_iter)[0] - frame_bytes = io.BytesIO() - frame.save(frame_bytes, writer.IMAGE_EXT) - frame_bytes.seek(0) + if hasattr(db_data, "video"): + # Decoded video frames can have different size, restore the original one - else: - # Populate skipped frames with placeholder data, - # this is required for video chunk decoding implementation in UI - frame_bytes = io.BytesIO(dummy_frame.getvalue()) + frame = frame.to_image() + if frame.size != frame_size: + frame = frame.resize(frame_size) + else: + # Populate skipped frames with placeholder data, + # this is required for video chunk decoding implementation in UI + # TODO: try to fix decoding in UI + frame = io.BytesIO(dummy_frame.getvalue()) - if frame_bytes is not None: - chunk_frames.append((frame_bytes, None, None)) + yield (frame, None, None) buff = io.BytesIO() - writer.save_as_chunk( - chunk_frames, - buff, - compress_frames=False, - zip_compress_level=1, # there are likely to be many skips in SPECIFIC_FRAMES segments - ) - buff.seek(0) + with closing(get_frames()) as frame_iter: + writer.save_as_chunk( + frame_iter, + buff, + zip_compress_level=1, + # there are likely to be many skips with repeated placeholder frames + # in SPECIFIC_FRAMES segments, it makes sense to compress the archive + ) + buff.seek(0) return buff, get_chunk_mime_type_for_writer(writer) def _prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: From c5a1197f41b6664b098b1d8b68a57dc55f5495ef Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 6 Sep 2024 19:58:43 +0300 Subject: [PATCH 124/227] Remove the breaking change in the chunk retrieval API, add a new index parameter --- .../20240812_161617_mzhiltso_job_chunks.md | 14 ++- cvat/apps/engine/cache.py | 24 +++- cvat/apps/engine/frame_provider.py | 117 ++++++++++++++++-- cvat/apps/engine/views.py | 60 +++++++-- cvat/schema.yml | 9 +- tests/python/rest_api/test_tasks.py | 67 ++++++++-- 6 files changed, 257 insertions(+), 34 deletions(-) diff --git a/changelog.d/20240812_161617_mzhiltso_job_chunks.md b/changelog.d/20240812_161617_mzhiltso_job_chunks.md index b54c5fe9e223..6a3f609c02e5 100644 --- a/changelog.d/20240812_161617_mzhiltso_job_chunks.md +++ b/changelog.d/20240812_161617_mzhiltso_job_chunks.md @@ -2,15 +2,23 @@ - A server setting to disable media chunks on the local filesystem () +- \[Server API\] `GET /api/jobs/{id}/data/?type=chunk&index=x` parameter combination. + The new `index` parameter allows to retrieve job chunks using 0-based index in each job, + instead of the `number` parameter, which used task chunk ids. + () ### Changed -- \[Server API\] Chunk ids in each job now start from 0, instead of using ones from the task +- Job assignees will not receive frames from adjacent jobs in chunks + () + +### Deprecated + +- \[Server API\] `GET /api/jobs/{id}/data/?type=chunk&number=x` parameter combination () + ### Fixed - Various memory leaks in video reading on the server () -- Job assignees will not receive frames from adjacent jobs in the boundary chunks - () diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index c1116d8d88a6..6ace102040b9 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -121,11 +121,20 @@ def _make_chunk_key( *, quality: FrameQuality, ) -> str: - return f"{self._make_cache_key_prefix(db_obj)}_{chunk_number}_{quality}" + return f"{self._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" + def _make_segment_task_chunk_key( + self, + db_obj: models.Segment, + chunk_number: int, + *, + quality: FrameQuality, + ) -> str: + return f"{self._make_cache_key_prefix(db_obj)}_task_chunk_{chunk_number}_{quality}" + def _make_context_image_preview_key(self, db_data: models.Data, frame_number: int) -> str: return f"context_image_{db_data.id}_{frame_number}_preview" @@ -157,6 +166,19 @@ def get_or_set_task_chunk( create_callback=set_callback, ) + def get_or_set_segment_task_chunk( + self, + db_segment: models.Segment, + chunk_number: int, + *, + quality: FrameQuality, + set_callback: Callable[[], DataWithMime], + ) -> DataWithMime: + return self._get_or_set_cache_item( + key=self._make_segment_task_chunk_key(db_segment, chunk_number, quality=quality), + create_callback=set_callback, + ) + def get_selective_job_chunk( self, db_job: models.Job, chunk_number: int, *, quality: FrameQuality ) -> DataWithMime: diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 0821271fd1ab..71414490c8a5 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -12,11 +12,24 @@ from dataclasses import dataclass from enum import Enum, auto from io import BytesIO -from typing import Any, Callable, Generic, Iterator, Optional, Tuple, Type, TypeVar, Union, overload +from typing import ( + Any, + Callable, + Generic, + Iterator, + Optional, + Sequence, + Tuple, + Type, + TypeVar, + Union, + overload, +) import av import cv2 import numpy as np +from datumaro.util import take_by from django.conf import settings from PIL import Image from rest_framework.exceptions import ValidationError @@ -255,13 +268,12 @@ def get_chunk( return_type = DataWithMeta[BytesIO] chunk_number = self.validate_chunk_number(chunk_number) - db_data = self._db_task.data - cache = MediaCache() cached_chunk = cache.get_task_chunk(self._db_task, chunk_number, quality=quality) if cached_chunk: return return_type(cached_chunk[0], cached_chunk[1]) + db_data = self._db_task.data step = db_data.get_frame_step() task_chunk_start_frame = chunk_number * db_data.chunk_size task_chunk_stop_frame = (chunk_number + 1) * db_data.chunk_size - 1 @@ -273,7 +285,7 @@ def get_chunk( ) ) - matching_segments = sorted( + matching_segments: list[models.Segment] = sorted( [ s for s in self._db_task.segment_set.all() @@ -285,13 +297,15 @@ def get_chunk( assert matching_segments # Don't put this into set_callback to avoid data duplication in the cache - if len(matching_segments) == 1 and task_chunk_frame_set == set( - matching_segments[0].frame_set - ): + + if len(matching_segments) == 1: segment_frame_provider = SegmentFrameProvider(matching_segments[0]) - return segment_frame_provider.get_chunk( - segment_frame_provider.get_chunk_number(task_chunk_start_frame), quality=quality + matching_chunk_index = segment_frame_provider.find_matching_chunk( + sorted(task_chunk_frame_set) ) + if matching_chunk_index is not None: + # 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 @@ -469,6 +483,20 @@ def validate_frame_number(self, frame_number: int) -> Tuple[int, int, int]: def get_chunk_number(self, frame_number: int) -> int: return int(frame_number) // self._db_segment.task.data.chunk_size + def find_matching_chunk(self, frames: Sequence[int]) -> Optional[int]: + return next( + ( + i + for i, chunk_frames in enumerate( + take_by( + sorted(self._db_segment.frame_set), self._db_segment.task.data.chunk_size + ) + ) + if frames == set(chunk_frames) + ), + None, + ) + def validate_chunk_number(self, chunk_number: int) -> int: segment_size = self._db_segment.frame_count last_chunk = math.ceil(segment_size / self._db_segment.task.data.chunk_size) - 1 @@ -560,6 +588,77 @@ class JobFrameProvider(SegmentFrameProvider): def __init__(self, db_job: models.Job) -> None: super().__init__(db_job.segment) + def get_chunk( + self, + chunk_number: int, + *, + quality: FrameQuality = FrameQuality.ORIGINAL, + is_task_chunk: bool = False, + ) -> DataWithMeta[BytesIO]: + if not is_task_chunk: + return super().get_chunk(chunk_number, quality=quality) + + task_frame_provider = TaskFrameProvider(self._db_segment.task) + segment_start_chunk = task_frame_provider.get_chunk_number(self._db_segment.start_frame) + segment_stop_chunk = task_frame_provider.get_chunk_number(self._db_segment.stop_frame) + if not segment_start_chunk <= chunk_number <= segment_stop_chunk: + raise ValidationError( + f"Invalid chunk number '{chunk_number}'. " + "The chunk number should be in the " + f"[{segment_start_chunk}, {segment_stop_chunk}] range" + ) + + # Reproduce the task chunks, limited by this job + return_type = DataWithMeta[BytesIO] + + cache = MediaCache() + cached_chunk = cache.get_task_chunk(self._db_segment.task, chunk_number, quality=quality) + if cached_chunk: + return return_type(cached_chunk[0], cached_chunk[1]) + + db_data = self._db_segment.task.data + step = db_data.get_frame_step() + task_chunk_start_frame = chunk_number * db_data.chunk_size + task_chunk_stop_frame = (chunk_number + 1) * db_data.chunk_size - 1 + task_chunk_frame_set = set( + range( + db_data.start_frame + task_chunk_start_frame * step, + min(db_data.start_frame + task_chunk_stop_frame * step, db_data.stop_frame) + step, + step, + ) + ) + + # Don't put this into set_callback to avoid data duplication in the cache + matching_chunk = self.find_matching_chunk(sorted(task_chunk_frame_set)) + 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_frame_set = set(self._db_segment.frame_set) + task_chunk_frames = {} + for task_chunk_frame_id in sorted(task_chunk_frame_set): + if task_chunk_frame_id not in segment_frame_set: + continue + + frame, frame_name, _ = self._get_raw_frame( + task_frame_provider.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_segment.task, + dump_unchanged=True, + ) + + buffer, mime_type = cache.get_or_set_segment_task_chunk( + self._db_segment, chunk_number, quality=quality, set_callback=_set_callback + ) + + return return_type(data=buffer, mime=mime_type) + @overload def make_frame_provider(data_source: models.Job) -> JobFrameProvider: ... diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 1bc2921605ac..ebda8c7fae33 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -685,8 +685,7 @@ def __init__( if data_quality == 'compressed' else FrameQuality.ORIGINAL @abstractmethod - def _get_frame_provider(self) -> IFrameProvider: - ... + def _get_frame_provider(self) -> IFrameProvider: ... def __call__(self): frame_provider = self._get_frame_provider() @@ -694,7 +693,7 @@ def __call__(self): try: if self.type == 'chunk': data = frame_provider.get_chunk(self.number, quality=self.quality) - return HttpResponse(data.data.getvalue(), content_type=data.mime) # TODO: add new headers + return HttpResponse(data.data.getvalue(), content_type=data.mime) elif self.type == 'frame' or self.type == 'preview': if self.type == 'preview': data = frame_provider.get_preview() @@ -729,7 +728,7 @@ def __init__( super().__init__(data_type=data_type, data_num=data_num, data_quality=data_quality) self._db_task = db_task - def _get_frame_provider(self) -> IFrameProvider: + def _get_frame_provider(self) -> TaskFrameProvider: return TaskFrameProvider(self._db_task) @@ -741,13 +740,47 @@ def __init__( data_type: str, data_quality: str, data_num: Optional[Union[str, int]] = None, + data_index: Optional[Union[str, int]] = None, ) -> None: - super().__init__(data_type=data_type, data_num=data_num, data_quality=data_quality) + possible_data_type_values = ('chunk', 'frame', 'preview', 'context_image') + possible_quality_values = ('compressed', 'original') + + if not data_type or data_type not in possible_data_type_values: + raise ValidationError('Data type not specified or has wrong value') + elif data_type == 'chunk' or data_type == 'frame' or data_type == 'preview': + if data_type == 'chunk': + if data_num is None and data_index is None: + raise ValidationError('Number or Index is not specified') + if data_num is not None and data_index is not None: + raise ValidationError('Number and Index cannot be used together') + elif data_num is None and data_type != 'preview': + raise ValidationError('Number is not specified') + elif data_quality not in possible_quality_values: + raise ValidationError('Wrong quality value') + + self.type = data_type + + self.number = int(data_index) if data_index is not None else None + self.task_chunk_number = int(data_num) if data_num is not None else None + + self.quality = FrameQuality.COMPRESSED \ + if data_quality == 'compressed' else FrameQuality.ORIGINAL + self._db_job = db_job - def _get_frame_provider(self) -> IFrameProvider: + def _get_frame_provider(self) -> JobFrameProvider: return JobFrameProvider(self._db_job) + def __call__(self): + if self.type == 'chunk' and self.task_chunk_number is not None: + # Reproduce the task chunk indexing + frame_provider = self._get_frame_provider() + data = frame_provider.get_chunk( + self.task_chunk_number, quality=self.quality, is_task_chunk=True + ) + return HttpResponse(data.data.getvalue(), content_type=data.mime) + else: + return super().__call__() @extend_schema(tags=['tasks']) @extend_schema_view( @@ -1987,8 +2020,14 @@ def get_export_callback(self, save_images: bool) -> Callable: OpenApiParameter('quality', location=OpenApiParameter.QUERY, required=False, type=OpenApiTypes.STR, enum=['compressed', 'original'], description="Specifies the quality level of the requested data"), - OpenApiParameter('number', location=OpenApiParameter.QUERY, required=False, type=OpenApiTypes.INT, - description="A unique number value identifying chunk or frame"), + OpenApiParameter('number', + location=OpenApiParameter.QUERY, required=False, type=OpenApiTypes.INT, + description="A unique number value identifying chunk or frame. " + "The numbers are the same as for the task. " + "Deprecated for chunks in favor of 'index'"), + OpenApiParameter('index', + location=OpenApiParameter.QUERY, required=False, type=OpenApiTypes.INT, + description="A unique number value identifying chunk, starts from 0 for each job"), ], responses={ '200': OpenApiResponse(OpenApiTypes.BINARY, description='Data of a specific type'), @@ -2000,10 +2039,13 @@ def data(self, request, pk): db_job = self.get_object() # call check_object_permissions as well data_type = request.query_params.get('type', None) data_num = request.query_params.get('number', None) + data_index = request.query_params.get('index', None) data_quality = request.query_params.get('quality', 'compressed') data_getter = _JobDataGetter( - db_job, data_type=data_type, data_num=data_num, data_quality=data_quality + db_job, + data_type=data_type, data_quality=data_quality, + data_index=data_index, data_num=data_num ) return data_getter() diff --git a/cvat/schema.yml b/cvat/schema.yml index 96ece3d07ca8..0290ce2a5ddf 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -2322,11 +2322,18 @@ paths: type: integer description: A unique integer value identifying this job. required: true + - in: query + name: index + schema: + type: integer + description: A unique number value identifying chunk, starts from 0 for each + job - in: query name: number schema: type: integer - description: A unique number value identifying chunk or frame + description: A unique number value identifying chunk or frame. The numbers + are the same as for the task. Deprecated for chunks in favor of 'index' - in: query name: quality schema: diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 5a3d28c131c9..4c3c3eee5f57 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -22,7 +22,7 @@ from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory from time import sleep, time -from typing import Any, Callable, ClassVar, Dict, Generator, List, Optional, Tuple, Union +from typing import Any, Callable, ClassVar, Dict, Generator, List, Optional, Sequence, Tuple, Union import attrs import numpy as np @@ -2444,12 +2444,16 @@ def test_can_get_job_frames(self, task_spec: _TaskSpec, task_id: int): ) @parametrize("task_spec, task_id", _default_task_cases) - def test_can_get_job_chunks(self, task_spec: _TaskSpec, task_id: int): + @parametrize("indexing", ["absolute", "relative"]) + def test_can_get_job_chunks(self, task_spec: _TaskSpec, task_id: int, indexing: str): with make_api_client(self._USERNAME) as api_client: jobs = sorted( get_paginated_collection(api_client.jobs_api.list_endpoint, task_id=task_id), key=lambda j: j.start_frame, ) + + (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) + for job in jobs: (job_meta, _) = api_client.jobs_api.retrieve_data_meta(job.id) @@ -2466,21 +2470,62 @@ def test_can_get_job_chunks(self, task_spec: _TaskSpec, task_id: int): else: assert False - chunk_count = math.ceil(job_meta.size / job_meta.chunk_size) - for quality, chunk_id in product(["original", "compressed"], range(chunk_count)): - expected_chunk_abs_frame_ids = range( - job_meta.start_frame - + chunk_id * job_meta.chunk_size * task_spec.frame_step, - job_meta.start_frame - + min((chunk_id + 1) * job_meta.chunk_size, job_meta.size) - * task_spec.frame_step, + if indexing == "absolute": + chunk_count = math.ceil(task_meta.size / job_meta.chunk_size) + + def get_task_chunk_abs_frame_ids(chunk_id: int) -> Sequence[int]: + return range( + task_meta.start_frame + + chunk_id * task_meta.chunk_size * task_spec.frame_step, + task_meta.start_frame + + min((chunk_id + 1) * task_meta.chunk_size, task_meta.size) + * task_spec.frame_step, + ) + + def get_job_frame_ids() -> Sequence[int]: + return range( + job_meta.start_frame, job_meta.stop_frame + 1, task_spec.frame_step + ) + + def get_expected_chunk_abs_frame_ids(chunk_id: int): + return sorted( + set(get_task_chunk_abs_frame_ids(chunk_id)) & set(get_job_frame_ids()) + ) + + job_chunk_ids = ( + task_chunk_id + for task_chunk_id in range(chunk_count) + if get_expected_chunk_abs_frame_ids(task_chunk_id) ) + else: + chunk_count = math.ceil(job_meta.size / job_meta.chunk_size) + job_chunk_ids = range(chunk_count) + + def get_expected_chunk_abs_frame_ids(chunk_id: int): + return range( + job_meta.start_frame + + chunk_id * job_meta.chunk_size * task_spec.frame_step, + job_meta.start_frame + + min((chunk_id + 1) * job_meta.chunk_size, job_meta.size) + * task_spec.frame_step, + ) + + for quality, chunk_id in product(["original", "compressed"], job_chunk_ids): + expected_chunk_abs_frame_ids = get_expected_chunk_abs_frame_ids(chunk_id) + + kwargs = {} + if indexing == "absolute": + kwargs["number"] = chunk_id + elif indexing == "relative": + kwargs["index"] = chunk_id + else: + assert False (_, response) = api_client.jobs_api.retrieve_data( job.id, type="chunk", quality=quality, - number=chunk_id, + **kwargs, _parse_response=False, ) From a5cf3b77f489ba99d75c121ca2d8adb935a4731b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 7 Sep 2024 12:42:12 +0300 Subject: [PATCH 125/227] Update UI to use the new chunk index parameter --- cvat-core/src/frames.ts | 2 +- cvat-core/src/server-proxy.ts | 2 +- cvat-core/src/session-implementation.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index ff6200ce91be..d2547f0010cf 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -587,7 +587,7 @@ export async function getFrame( isPlaying: boolean, step: number, dimension: DimensionType, - getChunk: (chunkNumber: number, quality: ChunkQuality) => Promise, + getChunk: (chunkIndex: number, quality: ChunkQuality) => Promise, ): Promise { if (!(jobID in frameDataCache)) { const blockType = chunkType === 'video' ? BlockType.MP4VIDEO : BlockType.ARCHIVE; diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index 91dc52a71821..51309426198a 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -1438,7 +1438,7 @@ async function getData(jid: number, chunk: number, quality: ChunkQuality, retry ...enableOrganization(), quality, type: 'chunk', - number: chunk, + index: chunk, }, responseType: 'arraybuffer', }); diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 4483c1113936..11430241caaf 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -189,7 +189,7 @@ export function implementJob(Job: typeof JobClass): typeof JobClass { isPlaying, step, this.dimension, - (chunkNumber, quality) => this.frames.chunk(chunkNumber, quality), + (chunkIndex, quality) => this.frames.chunk(chunkIndex, quality), ); }, }); @@ -273,10 +273,10 @@ export function implementJob(Job: typeof JobClass): typeof JobClass { Object.defineProperty(Job.prototype.frames.chunk, 'implementation', { value: function chunkImplementation( this: JobClass, - chunkNumber: Parameters[0], + chunkIndex: Parameters[0], quality: Parameters[1], ): ReturnType { - return serverProxy.frames.getData(this.id, chunkNumber, quality); + return serverProxy.frames.getData(this.id, chunkIndex, quality); }, }); @@ -824,7 +824,7 @@ export function implementTask(Task: typeof TaskClass): typeof TaskClass { isPlaying, step, this.dimension, - (chunkNumber, quality) => job.frames.chunk(chunkNumber, quality), + (chunkIndex, quality) => job.frames.chunk(chunkIndex, quality), ); return result; }, From cfdde3f86320ae498a4eef7b09de969bb9ae8d41 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 7 Sep 2024 12:50:30 +0300 Subject: [PATCH 126/227] Update test initialization --- cvat/apps/engine/tests/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat/apps/engine/tests/utils.py b/cvat/apps/engine/tests/utils.py index 87c5911e12b0..3d2a533d1e97 100644 --- a/cvat/apps/engine/tests/utils.py +++ b/cvat/apps/engine/tests/utils.py @@ -13,7 +13,7 @@ from django.core.cache import caches from django.http.response import HttpResponse from PIL import Image -from rest_framework.test import APIClient, APITestCase +from rest_framework.test import APITestCase import av import django_rq import numpy as np @@ -112,7 +112,7 @@ def setUp(self): self._clear_temp_data() super().setUp() - self.client = APIClient() + self.client = self.client_class() def generate_image_file(filename, size=(100, 100)): From 843b9571696edb9ff234935da6bdd50de7dd17f0 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 7 Sep 2024 12:58:24 +0300 Subject: [PATCH 127/227] Update changelog --- changelog.d/20240812_161617_mzhiltso_job_chunks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/20240812_161617_mzhiltso_job_chunks.md b/changelog.d/20240812_161617_mzhiltso_job_chunks.md index 6a3f609c02e5..af931641d6df 100644 --- a/changelog.d/20240812_161617_mzhiltso_job_chunks.md +++ b/changelog.d/20240812_161617_mzhiltso_job_chunks.md @@ -1,6 +1,6 @@ ### Added -- A server setting to disable media chunks on the local filesystem +- A server setting to enable or disable storage of permanent media chunks on the server filesystem () - \[Server API\] `GET /api/jobs/{id}/data/?type=chunk&index=x` parameter combination. The new `index` parameter allows to retrieve job chunks using 0-based index in each job, From feb92cd8ba99ef46873ece894bb63651a4a329f2 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 9 Sep 2024 19:33:42 +0300 Subject: [PATCH 128/227] Add backward compatibility for chunk "number" in GT jobs, remove placeholder frames from "index" response --- cvat/apps/engine/cache.py | 120 ++++++++++++++++++++++++---- cvat/apps/engine/frame_provider.py | 42 +++++----- tests/python/rest_api/test_jobs.py | 82 ++++++++++--------- tests/python/rest_api/test_tasks.py | 16 ++-- 4 files changed, 181 insertions(+), 79 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 6ace102040b9..8dd3f8f02b59 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -14,8 +14,19 @@ import zlib from contextlib import ExitStack, closing from datetime import datetime, timezone -from itertools import pairwise -from typing import Any, Callable, Generator, Iterator, Optional, Sequence, Tuple, Type, Union +from itertools import groupby, pairwise +from typing import ( + Any, + Callable, + Collection, + Generator, + Iterator, + Optional, + Sequence, + Tuple, + Type, + Union, +) import av import cv2 @@ -100,6 +111,9 @@ def _get(self, key: str) -> Optional[DataWithMime]: return item + def _has_key(self, key: str) -> bool: + return self._cache.has_key(key) + def _make_cache_key_prefix( self, obj: Union[models.Task, models.Segment, models.Job, models.CloudStorage] ) -> str: @@ -166,6 +180,13 @@ def get_or_set_task_chunk( create_callback=set_callback, ) + def get_segment_task_chunk( + self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality + ) -> Optional[DataWithMime]: + return self._get( + key=self._make_segment_task_chunk_key(db_segment, chunk_number, quality=quality) + ) + def get_or_set_segment_task_chunk( self, db_segment: models.Segment, @@ -361,7 +382,12 @@ def prepare_range_segment_chunk( chunk_size * chunk_number : chunk_size * (chunk_number + 1) ] - with closing(self._read_raw_frames(db_task, frame_ids=chunk_frame_ids)) as frame_iter: + return self.prepare_custom_range_segment_chunk(db_task, chunk_frame_ids, quality=quality) + + def prepare_custom_range_segment_chunk( + self, 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: return prepare_chunk(frame_iter, quality=quality, db_task=db_task) def prepare_masked_range_segment_chunk( @@ -371,41 +397,101 @@ def prepare_masked_range_segment_chunk( db_data = db_task.data chunk_size = db_data.chunk_size - chunk_frame_ids = list(db_segment.frame_set)[ + chunk_frame_ids = sorted(db_segment.frame_set)[ chunk_size * chunk_number : chunk_size * (chunk_number + 1) ] + + return self.prepare_custom_masked_range_segment_chunk( + db_task, chunk_frame_ids, chunk_number, quality=quality + ) + + def prepare_custom_masked_range_segment_chunk( + self, + db_task: models.Task, + frame_ids: Collection[int], + chunk_number: int, + *, + quality: FrameQuality, + insert_placeholders: bool = False, + ) -> DataWithMime: + db_data = db_task.data + frame_step = db_data.get_frame_step() - writer = ZipCompressedChunkWriter(db_data.image_quality, dimension=db_task.dimension) + image_quality = 100 if quality == FrameQuality.ORIGINAL else db_data.image_quality + writer = ZipCompressedChunkWriter(image_quality, dimension=db_task.dimension) dummy_frame = io.BytesIO() PIL.Image.new("RGB", (1, 1)).save(dummy_frame, writer.IMAGE_EXT) + # Optimize frame access if all the required frames are already cached + # Otherwise we might need to download files. + # This is not needed for video tasks, as it will reduce performance + from cvat.apps.engine.frame_provider import FrameOutputType, TaskFrameProvider + + task_frame_provider = TaskFrameProvider(db_task) + + use_cached_data = False + if db_task.mode != "interpolation": + required_frame_set = set(frame_ids) + all_chunks_available = all( + self._has_key(self._make_chunk_key(db_segment, chunk_number, quality=quality)) + for db_segment in db_task.segment_set.filter(type=models.SegmentType.RANGE).all() + for chunk_number, _ in groupby( + required_frame_set.intersection(db_segment.frame_set), + key=lambda frame: frame // db_data.chunk_size, + ) + ) + use_cached_data = all_chunks_available + if hasattr(db_data, "video"): frame_size = (db_data.video.width, db_data.video.height) else: frame_size = None def get_frames(): - with closing( - self._read_raw_frames(db_task, frame_ids=chunk_frame_ids) - ) as read_frame_iter: - for frame_idx in range(db_data.chunk_size): - frame_idx = ( - db_data.start_frame - + chunk_number * db_data.chunk_size - + frame_idx * frame_step + with ExitStack() as es: + es.callback(task_frame_provider.unload) + + if insert_placeholders: + frame_range = ( + ( + db_data.start_frame + + chunk_number * db_data.chunk_size + + chunk_frame_idx * frame_step + ) + for chunk_frame_idx in range(db_data.chunk_size) ) - if db_data.stop_frame < frame_idx: + else: + frame_range = frame_ids + + if not use_cached_data: + frames_gen = self._read_raw_frames(db_task, frame_ids) + frames_iter = iter(es.enter_context(closing(frames_gen))) + + for abs_frame_idx in frame_range: + if db_data.stop_frame < abs_frame_idx: break - if frame_idx in chunk_frame_ids: - frame = next(read_frame_iter)[0] + if abs_frame_idx in frame_ids: + if use_cached_data: + frame_data = task_frame_provider.get_frame( + task_frame_provider.get_rel_frame_number(abs_frame_idx), + quality=quality, + out_type=FrameOutputType.BUFFER, + ) + frame = frame_data.data + else: + frame, _, _ = next(frames_iter) if hasattr(db_data, "video"): # Decoded video frames can have different size, restore the original one - frame = frame.to_image() + if isinstance(frame, av.VideoFrame): + frame = frame.to_image() + else: + frame = PIL.Image.open(frame) + if frame.size != frame_size: frame = frame.resize(frame_size) else: diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 71414490c8a5..314c8cf5b23f 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -598,6 +598,10 @@ def get_chunk( if not is_task_chunk: return super().get_chunk(chunk_number, quality=quality) + # Backward compatibility for the "number" parameter + # Reproduce the task chunks, limited by this job + return_type = DataWithMeta[BytesIO] + task_frame_provider = TaskFrameProvider(self._db_segment.task) segment_start_chunk = task_frame_provider.get_chunk_number(self._db_segment.start_frame) segment_stop_chunk = task_frame_provider.get_chunk_number(self._db_segment.stop_frame) @@ -608,11 +612,8 @@ def get_chunk( f"[{segment_start_chunk}, {segment_stop_chunk}] range" ) - # Reproduce the task chunks, limited by this job - return_type = DataWithMeta[BytesIO] - cache = MediaCache() - cached_chunk = cache.get_task_chunk(self._db_segment.task, chunk_number, quality=quality) + cached_chunk = cache.get_segment_task_chunk(self._db_segment, chunk_number, quality=quality) if cached_chunk: return return_type(cached_chunk[0], cached_chunk[1]) @@ -635,23 +636,26 @@ def get_chunk( def _set_callback() -> DataWithMime: # Create and return a joined / cleaned chunk - segment_frame_set = set(self._db_segment.frame_set) - task_chunk_frames = {} - for task_chunk_frame_id in sorted(task_chunk_frame_set): - if task_chunk_frame_id not in segment_frame_set: - continue + segment_chunk_frame_ids = sorted( + task_chunk_frame_set.intersection(self._db_segment.frame_set) + ) - frame, frame_name, _ = self._get_raw_frame( - task_frame_provider.get_rel_frame_number(task_chunk_frame_id), quality=quality + 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, ) - task_chunk_frames[task_chunk_frame_id] = (frame, frame_name, None) - - return prepare_chunk( - task_chunk_frames.values(), - quality=quality, - db_task=self._db_segment.task, - dump_unchanged=True, - ) + 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 buffer, mime_type = cache.get_or_set_segment_task_chunk( self._db_segment, chunk_number, quality=quality, set_callback=_set_callback diff --git a/tests/python/rest_api/test_jobs.py b/tests/python/rest_api/test_jobs.py index 167b0d63c3b3..a6cd225a5d52 100644 --- a/tests/python/rest_api/test_jobs.py +++ b/tests/python/rest_api/test_jobs.py @@ -11,7 +11,7 @@ from copy import deepcopy from http import HTTPStatus from io import BytesIO -from itertools import product +from itertools import groupby, product from typing import Any, Dict, List, Optional, Tuple, Union import numpy as np @@ -603,12 +603,8 @@ def test_get_gt_job_in_org_task( self._test_get_job_403(user["username"], job["id"]) -@pytest.mark.usefixtures( - # if the db is restored per test, there are conflicts with the server data cache - # if we don't clean the db, the gt jobs created will be reused, and their - # ids won't conflict - "restore_db_per_class" -) +@pytest.mark.usefixtures("restore_db_per_class") +@pytest.mark.usefixtures("restore_redis_ondisk_per_class") class TestGetGtJobData: def _delete_gt_job(self, user, gt_job_id): with make_api_client(user) as api_client: @@ -715,7 +711,10 @@ def test_can_get_gt_job_meta_with_complex_frame_setup(self, admin_user, request) @pytest.mark.parametrize("task_mode", ["annotation", "interpolation"]) @pytest.mark.parametrize("quality", ["compressed", "original"]) - def test_can_get_gt_job_chunk(self, admin_user, tasks, jobs, task_mode, quality, request): + @pytest.mark.parametrize("indexing", ["absolute", "relative"]) + def test_can_get_gt_job_chunk( + self, admin_user, tasks, jobs, task_mode, quality, request, indexing + ): user = admin_user job_frame_count = 4 task = next( @@ -732,40 +731,49 @@ def test_can_get_gt_job_chunk(self, admin_user, tasks, jobs, task_mode, quality, (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) frame_step = int(task_meta.frame_filter.split("=")[-1]) if task_meta.frame_filter else 1 - job_frame_ids = list(range(task_meta.start_frame, task_meta.stop_frame, frame_step))[ - :job_frame_count - ] + task_frame_ids = range(task_meta.start_frame, task_meta.stop_frame + 1, frame_step) + rng = np.random.Generator(np.random.MT19937(42)) + job_frame_ids = sorted(rng.choice(task_frame_ids, job_frame_count, replace=False).tolist()) + gt_job = self._create_gt_job(admin_user, task_id, job_frame_ids) request.addfinalizer(lambda: self._delete_gt_job(admin_user, gt_job.id)) - with make_api_client(admin_user) as api_client: - (chunk_file, response) = api_client.jobs_api.retrieve_data( - gt_job.id, number=0, quality=quality, type="chunk" - ) - assert response.status == HTTPStatus.OK + if indexing == "absolute": + chunk_iter = groupby(task_frame_ids, key=lambda f: f // task_meta.chunk_size) + else: + chunk_iter = groupby(job_frame_ids, key=lambda f: f // task_meta.chunk_size) - frame_range = range( - task_meta.start_frame, min(task_meta.stop_frame + 1, task_meta.chunk_size), frame_step - ) - included_frames = job_frame_ids + for chunk_id, chunk_frames in chunk_iter: + chunk_frames = list(chunk_frames) - # The frame count is the same as in the whole range - # with placeholders in the frames outside the job. - # This is required by the UI implementation - with zipfile.ZipFile(chunk_file) as chunk: - assert set(chunk.namelist()) == set("{:06d}.jpeg".format(i) for i in frame_range) - - for file_info in chunk.filelist: - with chunk.open(file_info) as image_file: - image = Image.open(image_file) - image_data = np.array(image) - - if int(os.path.splitext(file_info.filename)[0]) not in included_frames: - assert image.size == (1, 1) - assert np.all(image_data == 0), image_data - else: - assert image.size > (1, 1) - assert np.any(image_data != 0) + if indexing == "absolute": + kwargs = {"number": chunk_id} + else: + kwargs = {"index": chunk_id} + + with make_api_client(admin_user) as api_client: + (chunk_file, response) = api_client.jobs_api.retrieve_data( + gt_job.id, **kwargs, quality=quality, type="chunk" + ) + assert response.status == HTTPStatus.OK + + # The frame count is the same as in the whole range + # with placeholders in the frames outside the job. + # This is required by the UI implementation + with zipfile.ZipFile(chunk_file) as chunk: + assert set(chunk.namelist()) == set( + f"{i:06d}.jpeg" for i in range(len(chunk_frames)) + ) + + for file_info in chunk.filelist: + with chunk.open(file_info) as image_file: + image = Image.open(image_file) + + chunk_frame_id = int(os.path.splitext(file_info.filename)[0]) + if chunk_frames[chunk_frame_id] not in job_frame_ids: + assert image.size == (1, 1) + else: + assert image.size > (1, 1) def _create_gt_job(self, user, task_id, frames): 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 3baa4f4228fc..eda54b8ddd0c 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -2496,12 +2496,16 @@ def get_expected_chunk_abs_frame_ids(chunk_id: int): job_chunk_ids = range(chunk_count) def get_expected_chunk_abs_frame_ids(chunk_id: int): - return range( - job_meta.start_frame - + chunk_id * job_meta.chunk_size * task_spec.frame_step, - job_meta.start_frame - + min((chunk_id + 1) * job_meta.chunk_size, job_meta.size) - * task_spec.frame_step, + return sorted( + frame + for frame in range( + job_meta.start_frame + + chunk_id * job_meta.chunk_size * task_spec.frame_step, + job_meta.start_frame + + min((chunk_id + 1) * job_meta.chunk_size, job_meta.size) + * task_spec.frame_step, + ) + if not job_meta.included_frames or frame in job_meta.included_frames ) for quality, chunk_id in product(["original", "compressed"], job_chunk_ids): From 2424f2b7821a0706915b5af57e7550bda3ae59ed Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 9 Sep 2024 19:36:35 +0300 Subject: [PATCH 129/227] Update UI to support job chunks with non-sequential frame ids --- cvat-core/src/frames.ts | 83 ++++++++++++++++++++++--------- cvat-data/src/ts/cvat-data.ts | 94 +++++++++++++++++++++++------------ cvat/apps/engine/cache.py | 1 - 3 files changed, 123 insertions(+), 55 deletions(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index d2547f0010cf..9b11fbfec34d 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -40,6 +40,10 @@ const frameDataCache: Record> = {}; +function rangeArray(start: number, end: number): number[] { + return Array.from({ length: end - start }, (v, k) => k + start); +} + export class FramesMetaData { public chunkSize: number; public deletedFrames: Record; @@ -144,6 +148,35 @@ export class FramesMetaData { resetUpdated(): void { this.#updateTrigger.reset(); } + + getFrameIndex(frameNumber: number): number { + if (frameNumber < this.startFrame || frameNumber > this.stopFrame) { + throw new ArgumentError(`Frame number ${frameNumber} doesn't belong to the job`); + } + + let frameIndex = null; + if (this.includedFrames) { + frameIndex = this.includedFrames.indexOf(frameNumber); // TODO: use binary search + if (frameIndex === -1) { + throw new ArgumentError(`Frame number ${frameNumber} doesn't belong to the job`); + } + } else { + frameIndex = frameNumber - this.startFrame; + } + return frameIndex; + } + + getFrameChunkIndex(frame_number: number): number { + return Math.floor(this.getFrameIndex(frame_number) / this.chunkSize); + } + + getFrameSequence(): number[] { + if (this.includedFrames) { + return this.includedFrames; + } + + return rangeArray(this.startFrame, this.stopFrame + 1); + } } export class FrameData { @@ -206,14 +239,12 @@ export class FrameData { } class PrefetchAnalyzer { - #chunkSize: number; #requestedFrames: number[]; - #startFrame: number; + #meta: FramesMetaData; - constructor(chunkSize, startFrame) { - this.#chunkSize = chunkSize; + constructor(meta: FramesMetaData) { this.#requestedFrames = []; - this.#startFrame = startFrame; + this.#meta = meta; } shouldPrefetchNext(current: number, isPlaying: boolean, isChunkCached: (chunk) => boolean): boolean { @@ -221,13 +252,13 @@ class PrefetchAnalyzer { return true; } - const currentChunk = Math.floor((current - this.#startFrame) / this.#chunkSize); + const currentChunk = this.#meta.getFrameChunkIndex(current); const { length } = this.#requestedFrames; const isIncreasingOrder = this.#requestedFrames .every((val, index) => index === 0 || val > this.#requestedFrames[index - 1]); if ( length && (isIncreasingOrder && current > this.#requestedFrames[length - 1]) && - ((current - this.#startFrame) % this.#chunkSize) >= Math.ceil(this.#chunkSize / 2) && + (this.#meta.getFrameIndex(current) % this.#meta.chunkSize) >= Math.ceil(this.#meta.chunkSize / 2) && !isChunkCached(currentChunk + 1) ) { // is increasing order including the current frame @@ -249,7 +280,7 @@ class PrefetchAnalyzer { this.#requestedFrames.push(frame); // only half of chunk size is considered in this logic - const limit = Math.ceil(this.#chunkSize / 2); + const limit = Math.ceil(this.#meta.chunkSize / 2); if (this.#requestedFrames.length > limit) { this.#requestedFrames.shift(); } @@ -264,24 +295,27 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { imageData: ImageBitmap | Blob; } | Blob>((resolve, reject) => { const { - provider, prefetchAnalyzer, chunkSize, startFrame, stopFrame, + meta, provider, prefetchAnalyzer, chunkSize, startFrame, stopFrame, decodeForward, forwardStep, decodedBlocksCacheSize, } = frameDataCache[this.jobID]; const requestId = +_.uniqueId(); - const chunkNumber = Math.floor((this.number - startFrame) / chunkSize); + const chunkNumber = meta.getFrameChunkIndex(this.number); const frame = provider.frame(this.number); - function findTheNextNotDecodedChunk(searchFrom: number): number { - let firstFrameInNextChunk = searchFrom + forwardStep; - let nextChunkNumber = Math.floor((firstFrameInNextChunk - startFrame) / chunkSize); + function findTheNextNotDecodedChunk( + searchFrom: number, isIndex: boolean = false, + ): number { + const currentFrameIndex = isIndex ? searchFrom : meta.getFrameIndex(searchFrom); + let nextFrameIndex = currentFrameIndex + forwardStep; + let nextChunkNumber = Math.floor(nextFrameIndex / chunkSize); while (nextChunkNumber === chunkNumber) { - firstFrameInNextChunk += forwardStep; - nextChunkNumber = Math.floor((firstFrameInNextChunk - startFrame) / chunkSize); + nextFrameIndex += forwardStep; + nextChunkNumber = Math.floor(nextFrameIndex / chunkSize); } if (provider.isChunkCached(nextChunkNumber)) { - return findTheNextNotDecodedChunk(firstFrameInNextChunk); + return findTheNextNotDecodedChunk(nextFrameIndex, true); } return nextChunkNumber; @@ -319,8 +353,10 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { provider.cleanup(1); provider.requestDecodeBlock( chunk, - startFrame + nextChunkNumber * chunkSize, - Math.min(stopFrame, startFrame + (nextChunkNumber + 1) * chunkSize - 1), + meta.getFrameSequence().slice( + nextChunkNumber * chunkSize, + (nextChunkNumber + 1) * chunkSize, + ), () => {}, releasePromise, releasePromise, @@ -381,8 +417,10 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { provider .requestDecodeBlock( chunk, - startFrame + chunkNumber * chunkSize, - Math.min(stopFrame, startFrame + (chunkNumber + 1) * chunkSize - 1), + meta.getFrameSequence().slice( + chunkNumber * chunkSize, + (chunkNumber + 1) * chunkSize, + ), (_frame: number, bitmap: ImageBitmap | Blob) => { if (decodeForward) { // resolve immediately only if is not playing @@ -613,12 +651,11 @@ export async function getFrame( forwardStep: step, provider: new FrameDecoder( blockType, - chunkSize, decodedBlocksCacheSize, - startFrame, + meta.getFrameChunkIndex.bind(meta), dimension, ), - prefetchAnalyzer: new PrefetchAnalyzer(chunkSize, startFrame), + prefetchAnalyzer: new PrefetchAnalyzer(meta), decodedBlocksCacheSize, activeChunkRequest: null, activeContextRequest: null, diff --git a/cvat-data/src/ts/cvat-data.ts b/cvat-data/src/ts/cvat-data.ts index ec9fc5cccc58..05aa359dc453 100644 --- a/cvat-data/src/ts/cvat-data.ts +++ b/cvat-data/src/ts/cvat-data.ts @@ -72,8 +72,8 @@ export function decodeContextImages( decodeContextImages.mutex = new Mutex(); interface BlockToDecode { - start: number; - end: number; + frameNumbers: number[]; + chunkNumber: number; block: ArrayBuffer; onDecodeAll(): void; onDecode(frame: number, bitmap: ImageBitmap | Blob): void; @@ -82,7 +82,6 @@ interface BlockToDecode { export class FrameDecoder { private blockType: BlockType; - private chunkSize: number; /* ImageBitmap when decode zip or video chunks Blob when 3D dimension @@ -100,13 +99,12 @@ export class FrameDecoder { private renderHeight: number; private zipWorker: Worker | null; private videoWorker: Worker | null; - private startFrame: number; + private getChunkNumber: (frame: number) => number; constructor( blockType: BlockType, - chunkSize: number, cachedBlockCount: number, - startFrame: number, + getChunkNumber: (frame: number) => number, dimension: DimensionType = DimensionType.DIMENSION_2D, ) { this.mutex = new Mutex(); @@ -119,8 +117,7 @@ export class FrameDecoder { this.renderWidth = 1920; this.renderHeight = 1080; - this.chunkSize = chunkSize; - this.startFrame = startFrame; + this.getChunkNumber = getChunkNumber; this.blockType = blockType; this.decodedChunks = {}; @@ -158,17 +155,43 @@ export class FrameDecoder { } } + private validateFrameNumbers(frameNumbers: number[]): void { + if (!frameNumbers || !frameNumbers.length) { + throw new Error('frameNumbers must not be empty'); + } + + // ensure is ordered + for (let i = 1; i < frameNumbers.length; ++i) { + const prev = frameNumbers[i - 1]; + const current = frameNumbers[i]; + if (current <= prev) { + throw new Error( + 'frameNumbers must be sorted in ascending order, ' + + `got a (${prev}, ${current}) pair instead`, + ); + } + } + } + + private arraysEqual(a: number[], b: number[]): boolean { + return ( + a.length === b.length && + a.every((element, index) => element === b[index]) + ); + } + requestDecodeBlock( block: ArrayBuffer, - start: number, - end: number, + frameNumbers: number[], onDecode: (frame: number, bitmap: ImageBitmap | Blob) => void, onDecodeAll: () => void, onReject: (e: Error) => void, ): void { + this.validateFrameNumbers(frameNumbers); + if (this.requestedChunkToDecode !== null) { // a chunk was already requested to be decoded, but decoding didn't start yet - if (start === this.requestedChunkToDecode.start && end === this.requestedChunkToDecode.end) { + if (this.arraysEqual(frameNumbers, this.requestedChunkToDecode.frameNumbers)) { // it was the same chunk this.requestedChunkToDecode.onReject(new RequestOutdatedError()); @@ -178,12 +201,14 @@ export class FrameDecoder { // it was other chunk this.requestedChunkToDecode.onReject(new RequestOutdatedError()); } - } else if (this.chunkIsBeingDecoded === null || this.chunkIsBeingDecoded.start !== start) { + } else if (this.chunkIsBeingDecoded === null || + !this.arraysEqual(frameNumbers, this.requestedChunkToDecode.frameNumbers) + ) { // everything was decoded or decoding other chunk is in process this.requestedChunkToDecode = { + frameNumbers, + chunkNumber: this.getChunkNumber(frameNumbers[0]), block, - start, - end, onDecode, onDecodeAll, onReject, @@ -206,7 +231,7 @@ export class FrameDecoder { } frame(frameNumber: number): ImageBitmap | Blob | null { - const chunkNumber = Math.floor((frameNumber - this.startFrame) / this.chunkSize); + const chunkNumber = this.getChunkNumber(frameNumber); if (chunkNumber in this.decodedChunks) { return this.decodedChunks[chunkNumber][frameNumber]; } @@ -256,8 +281,8 @@ export class FrameDecoder { releaseMutex(); }; try { - const { start, end, block } = this.requestedChunkToDecode; - if (start !== blockToDecode.start) { + const { frameNumbers, chunkNumber, block } = this.requestedChunkToDecode; + if (!this.arraysEqual(frameNumbers, blockToDecode.frameNumbers)) { // request is not relevant, another block was already requested // it happens when A is being decoded, B comes and wait for mutex, C comes and wait for mutex // B is not necessary anymore, because C already was requested @@ -265,7 +290,8 @@ export class FrameDecoder { throw new RequestOutdatedError(); } - const chunkNumber = Math.floor((start - this.startFrame) / this.chunkSize); + const getFrameNumber = (chunkFrameIndex: number): number => frameNumbers[chunkFrameIndex]; + this.orderedStack = [chunkNumber, ...this.orderedStack]; this.cleanup(); const decodedFrames: Record = {}; @@ -276,7 +302,7 @@ export class FrameDecoder { this.videoWorker = new Worker( new URL('./3rdparty/Decoder.worker', import.meta.url), ); - let index = start; + let index = 0; this.videoWorker.onmessage = (e) => { if (e.data.consoleLog) { @@ -284,6 +310,7 @@ export class FrameDecoder { return; } const keptIndex = index; + const frameNumber = getFrameNumber(keptIndex); // do not use e.data.height and e.data.width because they might be not correct // instead, try to understand real height and width of decoded image via scale factor @@ -298,10 +325,10 @@ export class FrameDecoder { width, height, )).then((bitmap) => { - decodedFrames[keptIndex] = bitmap; - this.chunkIsBeingDecoded.onDecode(keptIndex, decodedFrames[keptIndex]); + decodedFrames[frameNumber] = bitmap; + this.chunkIsBeingDecoded.onDecode(frameNumber, decodedFrames[frameNumber]); - if (keptIndex === end) { + if (keptIndex === frameNumbers.length - 1) { this.decodedChunks[chunkNumber] = decodedFrames; this.chunkIsBeingDecoded.onDecodeAll(); this.chunkIsBeingDecoded = null; @@ -346,7 +373,7 @@ export class FrameDecoder { this.zipWorker = this.zipWorker || new Worker( new URL('./unzip_imgs.worker', import.meta.url), ); - let index = start; + let decodedCount = 0; this.zipWorker.onmessage = async (event) => { if (event.data.error) { @@ -356,16 +383,18 @@ export class FrameDecoder { return; } - decodedFrames[event.data.index] = event.data.data as ImageBitmap | Blob; - this.chunkIsBeingDecoded.onDecode(event.data.index, decodedFrames[event.data.index]); + const frameNumber = getFrameNumber(event.data.index); + decodedFrames[frameNumber] = event.data.data as ImageBitmap | Blob; + this.chunkIsBeingDecoded.onDecode(frameNumber, decodedFrames[frameNumber]); - if (index === end) { + if (decodedCount === frameNumbers.length - 1) { this.decodedChunks[chunkNumber] = decodedFrames; this.chunkIsBeingDecoded.onDecodeAll(); this.chunkIsBeingDecoded = null; release(); } - index++; + + decodedCount++; }; this.zipWorker.onerror = (event: ErrorEvent) => { @@ -376,8 +405,8 @@ export class FrameDecoder { this.zipWorker.postMessage({ block, - start, - end, + start: 0, + end: frameNumbers.length - 1, dimension: this.dimension, dimension2D: DimensionType.DIMENSION_2D, }); @@ -403,8 +432,11 @@ export class FrameDecoder { } public cachedChunks(includeInProgress = false): number[] { - const chunkIsBeingDecoded = includeInProgress && this.chunkIsBeingDecoded ? - Math.floor(this.chunkIsBeingDecoded.start / this.chunkSize) : null; + const chunkIsBeingDecoded = ( + includeInProgress && this.chunkIsBeingDecoded ? + this.chunkIsBeingDecoded.chunkNumber : + null + ); return Object.keys(this.decodedChunks).map((chunkNumber: string) => +chunkNumber).concat( ...(chunkIsBeingDecoded !== null ? [chunkIsBeingDecoded] : []), ).sort((a, b) => a - b); diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 8dd3f8f02b59..0d1699b4fdd7 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -497,7 +497,6 @@ def get_frames(): else: # Populate skipped frames with placeholder data, # this is required for video chunk decoding implementation in UI - # TODO: try to fix decoding in UI frame = io.BytesIO(dummy_frame.getvalue()) yield (frame, None, None) From fe60bdf7cfd3c76fd148f7c79b67d2c1b2e63dfd Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 9 Sep 2024 21:14:11 +0300 Subject: [PATCH 130/227] Fix job frame retrieval --- cvat/apps/engine/views.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index ebda8c7fae33..e30a857d3010 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -760,8 +760,8 @@ def __init__( self.type = data_type - self.number = int(data_index) if data_index is not None else None - self.task_chunk_number = int(data_num) if data_num is not None else None + self.index = int(data_index) if data_index is not None else None + self.number = int(data_num) if data_num is not None else None self.quality = FrameQuality.COMPRESSED \ if data_quality == 'compressed' else FrameQuality.ORIGINAL @@ -772,12 +772,19 @@ def _get_frame_provider(self) -> JobFrameProvider: return JobFrameProvider(self._db_job) def __call__(self): - if self.type == 'chunk' and self.task_chunk_number is not None: + if self.type == 'chunk': # Reproduce the task chunk indexing frame_provider = self._get_frame_provider() - data = frame_provider.get_chunk( - self.task_chunk_number, quality=self.quality, is_task_chunk=True - ) + + 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) else: return super().__call__() From 6ddb6bf8fef3276e3667646b7f08c8da01d768f2 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 10 Sep 2024 00:40:09 +0300 Subject: [PATCH 131/227] Fix 3d task chunk writing --- cvat/apps/engine/media_extractors.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 1d5c6fde76e5..9ddbad10e3a8 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -914,7 +914,10 @@ def save_as_chunk(self, images: Iterator[tuple[Image.Image|io.IOBase|str, str, s else: output = path else: - output, ext = self._write_pcd_file(path)[0:2] + if isinstance(image, io.BytesIO): + output, ext = self._write_pcd_file(image)[0:2] + else: + output, ext = self._write_pcd_file(path)[0:2] arcname = '{:06d}.{}'.format(idx, ext) if isinstance(output, io.BytesIO): @@ -945,7 +948,11 @@ def save_as_chunk( w, h = img.size extension = self.IMAGE_EXT else: - image_buf, extension, w, h = self._write_pcd_file(path) + if isinstance(image, io.BytesIO): + image_buf, extension, w, h = self._write_pcd_file(image) + else: + image_buf, extension, w, h = self._write_pcd_file(path) + image_sizes.append((w, h)) arcname = '{:06d}.{}'.format(idx, extension) zip_chunk.writestr(arcname, image_buf.getvalue()) From 4fa7b9762fccf1be981d8266c3722d97b8340168 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 10 Sep 2024 20:48:45 +0300 Subject: [PATCH 132/227] Fix frame retrieval in UI --- cvat-core/src/frames.ts | 109 ++++++++++++++++------ cvat-ui/src/actions/annotation-actions.ts | 3 +- 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index 9b11fbfec34d..bcba1899acb1 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -40,8 +40,11 @@ const frameDataCache: Record> = {}; -function rangeArray(start: number, end: number): number[] { - return Array.from({ length: end - start }, (v, k) => k + start); +function rangeArray(start: number, end: number, step: number = 1): number[] { + return Array.from( + { length: +(start < end) * Math.ceil((end - start) / step) }, + (v, k) => k * step + start, + ); } export class FramesMetaData { @@ -149,33 +152,46 @@ export class FramesMetaData { this.#updateTrigger.reset(); } - getFrameIndex(frameNumber: number): number { - if (frameNumber < this.startFrame || frameNumber > this.stopFrame) { - throw new ArgumentError(`Frame number ${frameNumber} doesn't belong to the job`); + getFrameIndex(dataFrameNumber: number): number { + // TODO: migrate to local frame numbers to simplify code + + if (dataFrameNumber < this.startFrame || dataFrameNumber > this.stopFrame) { + throw new ArgumentError(`Frame number ${dataFrameNumber} doesn't belong to the job`); } let frameIndex = null; if (this.includedFrames) { - frameIndex = this.includedFrames.indexOf(frameNumber); // TODO: use binary search + frameIndex = this.includedFrames.indexOf(dataFrameNumber); // TODO: use binary search if (frameIndex === -1) { - throw new ArgumentError(`Frame number ${frameNumber} doesn't belong to the job`); + throw new ArgumentError(`Frame number ${dataFrameNumber} doesn't belong to the job`); } } else { - frameIndex = frameNumber - this.startFrame; + frameIndex = Math.floor((dataFrameNumber - this.startFrame) / this.getFrameStep()); } return frameIndex; } - getFrameChunkIndex(frame_number: number): number { - return Math.floor(this.getFrameIndex(frame_number) / this.chunkSize); + getFrameChunkIndex(dataFrameNumber: number): number { + return Math.floor(this.getFrameIndex(dataFrameNumber) / this.chunkSize); } - getFrameSequence(): number[] { + getFrameStep(): number { + if (this.frameFilter) { + const frameStepParts = this.frameFilter.split('=', 2); + if (frameStepParts.length !== 2) { + throw new Error(`Invalid frame filter '${this.frameFilter}'`); + } + return parseInt(frameStepParts[1], 10); + } + return 1; + } + + getDataFrameNumbers(): number[] { if (this.includedFrames) { return this.includedFrames; } - return rangeArray(this.startFrame, this.stopFrame + 1); + return rangeArray(this.startFrame, this.stopFrame + 1, this.getFrameStep()); } } @@ -241,10 +257,12 @@ export class FrameData { class PrefetchAnalyzer { #requestedFrames: number[]; #meta: FramesMetaData; + #getDataFrameNumber: (frameNumber: number) => number; - constructor(meta: FramesMetaData) { + constructor(meta: FramesMetaData, dataFrameNumberGetter: (frameNumber: number) => number) { this.#requestedFrames = []; this.#meta = meta; + this.#getDataFrameNumber = dataFrameNumberGetter; } shouldPrefetchNext(current: number, isPlaying: boolean, isChunkCached: (chunk) => boolean): boolean { @@ -252,13 +270,16 @@ class PrefetchAnalyzer { return true; } - const currentChunk = this.#meta.getFrameChunkIndex(current); + const currentDataFrameNumber = this.#getDataFrameNumber(current); + const currentChunk = this.#meta.getFrameChunkIndex(currentDataFrameNumber); const { length } = this.#requestedFrames; const isIncreasingOrder = this.#requestedFrames .every((val, index) => index === 0 || val > this.#requestedFrames[index - 1]); if ( length && (isIncreasingOrder && current > this.#requestedFrames[length - 1]) && - (this.#meta.getFrameIndex(current) % this.#meta.chunkSize) >= Math.ceil(this.#meta.chunkSize / 2) && + ( + this.#meta.getFrameIndex(currentDataFrameNumber) % this.#meta.chunkSize + ) >= Math.ceil(this.#meta.chunkSize / 2) && !isChunkCached(currentChunk + 1) ) { // is increasing order including the current frame @@ -287,6 +308,18 @@ class PrefetchAnalyzer { } } +function getDataStartFrame(meta: FramesMetaData, localStartFrame: number): number { + return meta.startFrame - localStartFrame * meta.getFrameStep(); +} + +function getDataFrameNumber(frameNumber: number, dataStartFrame: number, step: number): number { + return frameNumber * step + dataStartFrame; +} + +function getFrameNumber(dataFrameNumber: number, dataStartFrame: number, step: number): number { + return (dataFrameNumber - dataStartFrame) / step; +} + Object.defineProperty(FrameData.prototype.data, 'implementation', { value(this: FrameData, onServerRequest) { return new Promise<{ @@ -300,14 +333,18 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { } = frameDataCache[this.jobID]; const requestId = +_.uniqueId(); - const chunkNumber = meta.getFrameChunkIndex(this.number); + const dataStartFrame = getDataStartFrame(meta, startFrame); + const requestedDataFrameNumber = getDataFrameNumber( + this.number, dataStartFrame, meta.getFrameStep(), + ); + const chunkNumber = meta.getFrameChunkIndex(requestedDataFrameNumber); + const segmentFrameNumbers = meta.getDataFrameNumbers().map( + (dataFrameNumber: number) => getFrameNumber(dataFrameNumber, dataStartFrame, meta.getFrameStep()), + ); const frame = provider.frame(this.number); - function findTheNextNotDecodedChunk( - searchFrom: number, isIndex: boolean = false, - ): number { - const currentFrameIndex = isIndex ? searchFrom : meta.getFrameIndex(searchFrom); - let nextFrameIndex = currentFrameIndex + forwardStep; + function findTheNextNotDecodedChunk(searchFrom: number): number { + let nextFrameIndex = searchFrom + forwardStep; let nextChunkNumber = Math.floor(nextFrameIndex / chunkSize); while (nextChunkNumber === chunkNumber) { nextFrameIndex += forwardStep; @@ -315,7 +352,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { } if (provider.isChunkCached(nextChunkNumber)) { - return findTheNextNotDecodedChunk(nextFrameIndex, true); + return findTheNextNotDecodedChunk(nextFrameIndex); } return nextChunkNumber; @@ -329,7 +366,9 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { (chunk) => provider.isChunkCached(chunk), ) && decodedBlocksCacheSize > 1 && !frameDataCache[this.jobID].activeChunkRequest ) { - const nextChunkNumber = findTheNextNotDecodedChunk(this.number); + const nextChunkNumber = findTheNextNotDecodedChunk( + meta.getFrameIndex(requestedDataFrameNumber), + ); const predecodeChunksMax = Math.floor(decodedBlocksCacheSize / 2); if (startFrame + nextChunkNumber * chunkSize <= stopFrame && nextChunkNumber <= chunkNumber + predecodeChunksMax @@ -353,7 +392,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { provider.cleanup(1); provider.requestDecodeBlock( chunk, - meta.getFrameSequence().slice( + segmentFrameNumbers.slice( nextChunkNumber * chunkSize, (nextChunkNumber + 1) * chunkSize, ), @@ -417,7 +456,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { provider .requestDecodeBlock( chunk, - meta.getFrameSequence().slice( + segmentFrameNumbers.slice( chunkNumber * chunkSize, (chunkNumber + 1) * chunkSize, ), @@ -641,6 +680,13 @@ export async function getFrame( const decodedBlocksCacheSize = Math.min( Math.floor((2048 * 1024 * 1024) / ((mean + stdDev) * 4 * chunkSize)) || 1, 10, ); + + // TODO: migrate to local frame numbers + const dataStartFrame = getDataStartFrame(meta, startFrame); + const dataFrameNumberGetter = (frameNumber: number): number => ( + getDataFrameNumber(frameNumber, dataStartFrame, meta.getFrameStep()) + ); + frameDataCache[jobID] = { meta, chunkSize, @@ -652,10 +698,12 @@ export async function getFrame( provider: new FrameDecoder( blockType, decodedBlocksCacheSize, - meta.getFrameChunkIndex.bind(meta), + (frameNumber: number): number => ( + meta.getFrameChunkIndex(dataFrameNumberGetter(frameNumber)) + ), dimension, ), - prefetchAnalyzer: new PrefetchAnalyzer(meta), + prefetchAnalyzer: new PrefetchAnalyzer(meta, dataFrameNumberGetter), decodedBlocksCacheSize, activeChunkRequest: null, activeContextRequest: null, @@ -729,8 +777,11 @@ export async function findFrame( let lastUndeletedFrame = null; const check = (frame): boolean => { if (meta.includedFrames) { - return (meta.includedFrames.includes(frame)) && - (!filters.notDeleted || !(frame in meta.deletedFrames)); + // meta.includedFrames contains input frame numbers now + const dataStartFrame = meta.startFrame; // this is only true when includedFrames is set + return (meta.includedFrames.includes( + getDataFrameNumber(frame, dataStartFrame, meta.getFrameStep())) + ) && (!filters.notDeleted || !(frame in meta.deletedFrames)); } if (filters.notDeleted) { return !(frame in meta.deletedFrames); diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index a5594093db51..c54d548b78d4 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -905,7 +905,8 @@ export function getJobAsync({ // frame query parameter does not work for GT job const frameNumber = Number.isInteger(initialFrame) && gtJob?.id !== job.id ? - initialFrame as number : (await job.frames.search( + initialFrame as number : + (await job.frames.search( { notDeleted: !showDeletedFrames }, job.startFrame, job.stopFrame, )) || job.startFrame; From 39e8f65d11f2d6a94afcd28a29da3b75152322d2 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 11 Sep 2024 11:24:22 +0300 Subject: [PATCH 133/227] Fix gt job segment type for honeypot gt jobs --- cvat/apps/engine/task.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index eb99d03f290d..6fc81958308e 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1449,13 +1449,24 @@ def _update_status(msg: str) -> None: # TODO: refactor if validation_params: - db_gt_segment = models.Segment( - task=db_task, - start_frame=0, - stop_frame=db_data.size - 1, - frames=validation_frames, - type=models.SegmentType.SPECIFIC_FRAMES, - ) + if validation_params['mode'] == models.ValidationMode.GT: + db_gt_segment = models.Segment( + task=db_task, + start_frame=0, + stop_frame=db_data.size - 1, + frames=validation_frames, + type=models.SegmentType.SPECIFIC_FRAMES, + ) + elif validation_params['mode'] == models.ValidationMode.GT_POOL: + db_gt_segment = models.Segment( + task=db_task, + start_frame=min(validation_frames), + stop_frame=max(validation_frames), + type=models.SegmentType.RANGE, + ) + else: + assert False + db_gt_segment.save() db_gt_job = models.Job(segment=db_gt_segment, type=models.JobType.GROUND_TRUTH) From 1b08e22f83fb08fb6f1d560b6e9a2fd103436bbb Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 11 Sep 2024 11:25:05 +0300 Subject: [PATCH 134/227] Fix chunk availability check --- cvat/apps/engine/cache.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index b1165e913d6b..ecabc7738876 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -448,15 +448,15 @@ def prepare_custom_masked_range_segment_chunk( use_cached_data = False if db_task.mode != "interpolation": required_frame_set = set(frame_ids) - all_chunks_available = all( + available_chunks = [ self._has_key(self._make_chunk_key(db_segment, chunk_number, quality=quality)) for db_segment in db_task.segment_set.filter(type=models.SegmentType.RANGE).all() for chunk_number, _ in groupby( - required_frame_set.intersection(db_segment.frame_set), + sorted(required_frame_set.intersection(db_segment.frame_set)), key=lambda frame: frame // db_data.chunk_size, ) - ) - use_cached_data = all_chunks_available + ] + use_cached_data = bool(available_chunks) and all(available_chunks) if hasattr(db_data, "video"): frame_size = (db_data.video.width, db_data.video.height) From 0e95b4016ffedfbec93ab7f4170b2a5b2b2e52b4 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 11 Sep 2024 11:25:50 +0300 Subject: [PATCH 135/227] Fix chunk availability check --- cvat/apps/engine/cache.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 0d1699b4fdd7..b32dbeef00e9 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -434,15 +434,15 @@ def prepare_custom_masked_range_segment_chunk( use_cached_data = False if db_task.mode != "interpolation": required_frame_set = set(frame_ids) - all_chunks_available = all( + available_chunks = [ self._has_key(self._make_chunk_key(db_segment, chunk_number, quality=quality)) for db_segment in db_task.segment_set.filter(type=models.SegmentType.RANGE).all() for chunk_number, _ in groupby( - required_frame_set.intersection(db_segment.frame_set), + sorted(required_frame_set.intersection(db_segment.frame_set)), key=lambda frame: frame // db_data.chunk_size, ) - ) - use_cached_data = all_chunks_available + ] + use_cached_data = bool(available_chunks) and all(available_chunks) if hasattr(db_data, "video"): frame_size = (db_data.video.width, db_data.video.height) From f775dcf91c98e443a3ca400cabfbb4c5803aff52 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 11 Sep 2024 18:43:21 +0300 Subject: [PATCH 136/227] Add data access tests with honeypot tasks --- cvat/apps/engine/models.py | 15 - tests/python/rest_api/test_tasks.py | 224 ++++++-- tests/python/shared/assets/cvat_db/data.json | 552 ++++++++++++++----- tests/python/shared/assets/tasks.json | 60 +- tests/python/shared/fixtures/init.py | 9 + 5 files changed, 651 insertions(+), 209 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 6ac89205b57e..1154ec1cdd36 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -823,21 +823,6 @@ def get_labels(self, prefetch=False): class Meta: default_permissions = () - @transaction.atomic - def save(self, *args, **kwargs) -> None: - self.full_clean() - return super().save(*args, **kwargs) - - def clean(self) -> None: - if not (self.type == JobType.GROUND_TRUTH) ^ (self.segment.type == SegmentType.RANGE): - raise ValidationError( - f"job type == {JobType.GROUND_TRUTH} and " - f"segment type == {SegmentType.SPECIFIC_FRAMES} " - "can only be used together" - ) - - return super().clean() - @cache_deleted @transaction.atomic(savepoint=False) def delete(self, using=None, keep_parents=False): diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index eda54b8ddd0c..a130f10d4afc 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -16,7 +16,7 @@ from enum import Enum from functools import partial from http import HTTPStatus -from itertools import chain, product +from itertools import chain, groupby, product from math import ceil from operator import itemgetter from pathlib import Path @@ -36,7 +36,7 @@ from cvat_sdk.core.uploading import Uploader from deepdiff import DeepDiff from PIL import Image -from pytest_cases import fixture_ref, parametrize +from pytest_cases import fixture, fixture_ref, parametrize import shared.utils.s3 as s3 from shared.fixtures.init import docker_exec_cvat, kube_exec_cvat @@ -2102,7 +2102,7 @@ def read_frame(self, i: int) -> Image.Image: @pytest.mark.usefixtures("restore_db_per_class") @pytest.mark.usefixtures("restore_redis_ondisk_per_class") -@pytest.mark.usefixtures("restore_cvat_data_per_function") +@pytest.mark.usefixtures("restore_cvat_data_per_class") class TestTaskData: _USERNAME = "admin1" @@ -2111,6 +2111,9 @@ def _uploaded_images_task_fxt_base( request: pytest.FixtureRequest, *, frame_count: int = 10, + start_frame: Optional[int] = None, + stop_frame: Optional[int] = None, + step: Optional[int] = None, segment_size: Optional[int] = None, ) -> Generator[Tuple[_TaskSpec, int], None, None]: task_params = { @@ -2125,8 +2128,18 @@ def _uploaded_images_task_fxt_base( data_params = { "image_quality": 70, "client_files": image_files, + "sorting_method": "natural", } + if start_frame is not None: + data_params["start_frame"] = start_frame + + if stop_frame is not None: + data_params["stop_frame"] = stop_frame + + if step is not None: + data_params["frame_filter"] = f"step={step}" + def get_frame(i: int) -> bytes: return images_data[i] @@ -2135,7 +2148,7 @@ def get_frame(i: int) -> bytes: models.TaskWriteRequest._from_openapi_data(**task_params), models.DataRequest._from_openapi_data(**data_params), get_frame=get_frame, - size=len(images_data), + size=len(range(start_frame or 0, (stop_frame or len(images_data) - 1) + 1, step or 1)), ), task_id @pytest.fixture(scope="class") @@ -2150,6 +2163,83 @@ def fxt_uploaded_images_task_with_segments( ) -> Generator[Tuple[_TaskSpec, int], None, None]: yield from self._uploaded_images_task_fxt_base(request=request, segment_size=4) + @fixture(scope="class") + @parametrize("step", [2, 5]) + @parametrize("stop_frame", [15, 26]) + @parametrize("start_frame", [3, 7]) + def fxt_uploaded_images_task_with_segments_start_stop_step( + self, request: pytest.FixtureRequest, start_frame: int, stop_frame: Optional[int], step: int + ) -> Generator[Tuple[_TaskSpec, int], None, None]: + yield from self._uploaded_images_task_fxt_base( + request=request, + frame_count=30, + segment_size=4, + start_frame=start_frame, + stop_frame=stop_frame, + step=step, + ) + + @pytest.fixture(scope="class") + def fxt_uploaded_images_task_with_segments_and_honeypots( + self, request: pytest.FixtureRequest + ) -> Generator[Tuple[_TaskSpec, int], None, None]: + validation_params = models.DataRequestValidationParams._from_openapi_data( + mode="gt_pool", + frame_selection_method="random_uniform", + random_seed=42, + frame_count=5, + frames_per_job_count=2, + ) + + base_segment_size = 4 + total_frame_count = 15 + regular_frame_count = 15 - validation_params.frame_count + final_segment_size = base_segment_size + validation_params.frames_per_job_count + final_task_size = ( + regular_frame_count + + validation_params.frames_per_job_count + * math.ceil(regular_frame_count / base_segment_size) + + validation_params.frame_count + ) + + task_params = { + "name": request.node.name, + "labels": [{"name": "a"}], + "segment_size": base_segment_size, + } + + image_files = generate_image_files(total_frame_count) + images_data = [f.getvalue() for f in image_files] + data_params = { + "image_quality": 70, + "client_files": image_files, + "sorting_method": "random", + "validation_params": validation_params, + } + + task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params) + + with make_api_client(self._USERNAME) as api_client: + (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) + frame_map = [ + next(i for i, f in enumerate(image_files) if f.name == frame_info.name) + for frame_info in task_meta.frames + ] + + def get_frame(i: int) -> bytes: + return images_data[frame_map[i]] + + task_spec = _ImagesTaskSpec( + models.TaskWriteRequest._from_openapi_data(**task_params), + models.DataRequest._from_openapi_data(**data_params), + get_frame=get_frame, + size=final_task_size, + ) + + task_spec._params.segment_size = final_segment_size + + yield task_spec, task_id + def _uploaded_video_task_fxt_base( self, request: pytest.FixtureRequest, @@ -2195,11 +2285,23 @@ def fxt_uploaded_video_task_with_segments( ) -> Generator[Tuple[_TaskSpec, int], None, None]: yield from self._uploaded_video_task_fxt_base(request=request, segment_size=4) - def _compute_segment_params(self, task_spec: _TaskSpec) -> List[Tuple[int, int]]: + def _compute_annotation_segment_params(self, task_spec: _TaskSpec) -> List[Tuple[int, int]]: segment_params = [] - segment_size = getattr(task_spec, "segment_size", 0) or task_spec.size + frame_step = task_spec.frame_step + segment_size = getattr(task_spec, "segment_size", 0) or task_spec.size * frame_step start_frame = getattr(task_spec, "start_frame", 0) - end_frame = (getattr(task_spec, "stop_frame", None) or (task_spec.size - 1)) + 1 + end_frame = ( + getattr(task_spec, "stop_frame", None) or ((task_spec.size - 1) * frame_step) + ) + frame_step + end_frame = end_frame - ((end_frame - frame_step - start_frame) % frame_step) + + validation_params = getattr(task_spec, "validation_params", None) + if validation_params and validation_params.mode.value == "gt_pool": + end_frame = min( + end_frame, (task_spec.size - validation_params.frame_count) * frame_step + ) + segment_size = min(segment_size, end_frame - 1) + overlap = min( ( getattr(task_spec, "overlap", None) or 0 @@ -2211,11 +2313,11 @@ def _compute_segment_params(self, task_spec: _TaskSpec) -> List[Tuple[int, int]] segment_start = start_frame while segment_start < end_frame: if start_frame < segment_start: - segment_start -= overlap * task_spec.frame_step + segment_start -= overlap * frame_step - segment_end = segment_start + task_spec.frame_step * segment_size + segment_end = segment_start + frame_step * segment_size - segment_params.append((segment_start, min(segment_end, end_frame) - 1)) + segment_params.append((segment_start, min(segment_end, end_frame) - frame_step)) segment_start = segment_end return segment_params @@ -2235,14 +2337,19 @@ def _compare_images( else: assert np.array_equal(chunk_frame_pixels, expected_pixels) - _default_task_cases = [ + _tasks_with_honeypots_cases = [ + fixture_ref("fxt_uploaded_images_task_with_segments_and_honeypots"), + ] + + _all_task_cases = [ fixture_ref("fxt_uploaded_images_task"), fixture_ref("fxt_uploaded_images_task_with_segments"), + fixture_ref("fxt_uploaded_images_task_with_segments_start_stop_step"), fixture_ref("fxt_uploaded_video_task"), fixture_ref("fxt_uploaded_video_task_with_segments"), - ] + ] + _tasks_with_honeypots_cases - @parametrize("task_spec, task_id", _default_task_cases) + @parametrize("task_spec, task_id", _all_task_cases) def test_can_get_task_meta(self, task_spec: _TaskSpec, task_id: int): with make_api_client(self._USERNAME) as api_client: (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) @@ -2265,7 +2372,7 @@ def test_can_get_task_meta(self, task_spec: _TaskSpec, task_id: int): else: assert len(task_meta.frames) == task_meta.size - @parametrize("task_spec, task_id", _default_task_cases) + @parametrize("task_spec, task_id", _all_task_cases) def test_can_get_task_frames(self, task_spec: _TaskSpec, task_id: int): with make_api_client(self._USERNAME) as api_client: (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) @@ -2275,8 +2382,8 @@ def test_can_get_task_frames(self, task_spec: _TaskSpec, task_id: int): range(task_meta.start_frame, task_meta.stop_frame + 1, task_spec.frame_step), ): rel_frame_id = ( - abs_frame_id - getattr(task_spec, "start_frame", 0) // task_spec.frame_step - ) + abs_frame_id - getattr(task_spec, "start_frame", 0) + ) // task_spec.frame_step (_, response) = api_client.tasks_api.retrieve_data( task_id, type="frame", @@ -2305,7 +2412,7 @@ def test_can_get_task_frames(self, task_spec: _TaskSpec, task_id: int): ), ) - @parametrize("task_spec, task_id", _default_task_cases) + @parametrize("task_spec, task_id", _all_task_cases) def test_can_get_task_chunks(self, task_spec: _TaskSpec, task_id: int): with make_api_client(self._USERNAME) as api_client: (task, _) = api_client.tasks_api.retrieve(task_id) @@ -2324,13 +2431,18 @@ def test_can_get_task_chunks(self, task_spec: _TaskSpec, task_id: int): else: assert False - chunk_count = math.ceil(task_meta.size / task_meta.chunk_size) - for quality, chunk_id in product(["original", "compressed"], range(chunk_count)): - expected_chunk_frame_ids = range( - chunk_id * task_meta.chunk_size, - min((chunk_id + 1) * task_meta.chunk_size, task_meta.size), + task_frames = range( + task_meta.start_frame, task_meta.stop_frame + 1, task_spec.frame_step + ) + task_chunk_frames = [ + (chunk_number, list(chunk_frames)) + for chunk_number, chunk_frames in groupby( + task_frames, key=lambda frame: frame // task_meta.chunk_size ) - + ] + for quality, (chunk_id, expected_chunk_frame_ids) in product( + ["original", "compressed"], task_chunk_frames + ): (_, response) = api_client.tasks_api.retrieve_data( task_id, type="chunk", quality=quality, number=chunk_id, _parse_response=False ) @@ -2360,29 +2472,32 @@ def test_can_get_task_chunks(self, task_spec: _TaskSpec, task_id: int): ), ) - @parametrize("task_spec, task_id", _default_task_cases) + @parametrize("task_spec, task_id", _all_task_cases) def test_can_get_job_meta(self, task_spec: _TaskSpec, task_id: int): - segment_params = self._compute_segment_params(task_spec) + segment_params = self._compute_annotation_segment_params(task_spec) + with make_api_client(self._USERNAME) as api_client: jobs = sorted( - get_paginated_collection(api_client.jobs_api.list_endpoint, task_id=task_id), + get_paginated_collection( + api_client.jobs_api.list_endpoint, task_id=task_id, type="annotation" + ), key=lambda j: j.start_frame, ) assert len(jobs) == len(segment_params) - for (segment_start, segment_end), job in zip(segment_params, jobs): + for (segment_start, segment_stop), job in zip(segment_params, jobs): (job_meta, _) = api_client.jobs_api.retrieve_data_meta(job.id) - assert (job_meta.start_frame, job_meta.stop_frame) == (segment_start, segment_end) + assert (job_meta.start_frame, job_meta.stop_frame) == (segment_start, segment_stop) assert job_meta.frame_filter == getattr(task_spec, "frame_filter", "") - segment_size = segment_end - segment_start + 1 + segment_size = math.ceil((segment_stop - segment_start + 1) / task_spec.frame_step) assert job_meta.size == segment_size - task_frame_set = set( + job_frame_set = set( range(job_meta.start_frame, job_meta.stop_frame + 1, task_spec.frame_step) ) - assert len(task_frame_set) == job_meta.size + assert len(job_frame_set) == job_meta.size if getattr(task_spec, "chunk_size", None): assert job_meta.chunk_size == task_spec.chunk_size @@ -2392,7 +2507,40 @@ def test_can_get_job_meta(self, task_spec: _TaskSpec, task_id: int): else: assert len(job_meta.frames) == job_meta.size - @parametrize("task_spec, task_id", _default_task_cases) + @parametrize("task_spec, task_id", _tasks_with_honeypots_cases) + def test_can_get_honeypot_gt_job_meta(self, task_spec: _TaskSpec, task_id: int): + with make_api_client(self._USERNAME) as api_client: + gt_jobs = get_paginated_collection( + api_client.jobs_api.list_endpoint, task_id=task_id, type="ground_truth" + ) + assert len(gt_jobs) == 1 + + gt_job = gt_jobs[0] + segment_start = task_spec.size - task_spec.validation_params.frame_count + segment_stop = task_spec.size - 1 + + (job_meta, _) = api_client.jobs_api.retrieve_data_meta(gt_job.id) + + assert (job_meta.start_frame, job_meta.stop_frame) == (segment_start, segment_stop) + assert job_meta.frame_filter == getattr(task_spec, "frame_filter", "") + + segment_size = math.ceil((segment_stop - segment_start + 1) / task_spec.frame_step) + assert job_meta.size == segment_size + + task_frame_set = set( + range(job_meta.start_frame, job_meta.stop_frame + 1, task_spec.frame_step) + ) + assert len(task_frame_set) == job_meta.size + + if getattr(task_spec, "chunk_size", None): + assert job_meta.chunk_size == task_spec.chunk_size + + if task_spec.source_data_type == _SourceDataType.video: + assert len(job_meta.frames) == 1 + else: + assert len(job_meta.frames) == job_meta.size + + @parametrize("task_spec, task_id", _all_task_cases) def test_can_get_job_frames(self, task_spec: _TaskSpec, task_id: int): with make_api_client(self._USERNAME) as api_client: jobs = sorted( @@ -2404,11 +2552,13 @@ def test_can_get_job_frames(self, task_spec: _TaskSpec, task_id: int): for quality, (frame_pos, abs_frame_id) in product( ["original", "compressed"], - enumerate(range(job_meta.start_frame, job_meta.stop_frame)), + enumerate( + range(job_meta.start_frame, job_meta.stop_frame, task_spec.frame_step) + ), ): rel_frame_id = ( - abs_frame_id - getattr(task_spec, "start_frame", 0) // task_spec.frame_step - ) + abs_frame_id - getattr(task_spec, "start_frame", 0) + ) // task_spec.frame_step (_, response) = api_client.jobs_api.retrieve_data( job.id, type="frame", @@ -2437,7 +2587,7 @@ def test_can_get_job_frames(self, task_spec: _TaskSpec, task_id: int): ), ) - @parametrize("task_spec, task_id", _default_task_cases) + @parametrize("task_spec, task_id", _all_task_cases) @parametrize("indexing", ["absolute", "relative"]) def test_can_get_job_chunks(self, task_spec: _TaskSpec, task_id: int, indexing: str): with make_api_client(self._USERNAME) as api_client: @@ -2474,6 +2624,7 @@ def get_task_chunk_abs_frame_ids(chunk_id: int) -> Sequence[int]: task_meta.start_frame + min((chunk_id + 1) * task_meta.chunk_size, task_meta.size) * task_spec.frame_step, + task_spec.frame_step, ) def get_job_frame_ids() -> Sequence[int]: @@ -2504,6 +2655,7 @@ def get_expected_chunk_abs_frame_ids(chunk_id: int): job_meta.start_frame + min((chunk_id + 1) * job_meta.chunk_size, job_meta.size) * task_spec.frame_step, + task_spec.frame_step, ) if not job_meta.included_frames or frame in job_meta.included_frames ) diff --git a/tests/python/shared/assets/cvat_db/data.json b/tests/python/shared/assets/cvat_db/data.json index 5bfe4eec6320..5826799d28bd 100644 --- a/tests/python/shared/assets/cvat_db/data.json +++ b/tests/python/shared/assets/cvat_db/data.json @@ -1577,7 +1577,9 @@ "path": "118.png", "frame": 0, "width": 940, - "height": 805 + "height": 805, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1588,7 +1590,9 @@ "path": "119.png", "frame": 1, "width": 693, - "height": 357 + "height": 357, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1599,7 +1603,9 @@ "path": "120.png", "frame": 2, "width": 254, - "height": 301 + "height": 301, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1610,7 +1616,9 @@ "path": "121.png", "frame": 3, "width": 918, - "height": 334 + "height": 334, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1621,7 +1629,9 @@ "path": "122.png", "frame": 4, "width": 619, - "height": 115 + "height": 115, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1632,7 +1642,9 @@ "path": "123.png", "frame": 5, "width": 599, - "height": 738 + "height": 738, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1643,7 +1655,9 @@ "path": "124.png", "frame": 6, "width": 306, - "height": 355 + "height": 355, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1654,7 +1668,9 @@ "path": "125.png", "frame": 7, "width": 838, - "height": 507 + "height": 507, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1665,7 +1681,9 @@ "path": "126.png", "frame": 8, "width": 885, - "height": 211 + "height": 211, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1676,7 +1694,9 @@ "path": "127.png", "frame": 9, "width": 553, - "height": 522 + "height": 522, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1687,7 +1707,9 @@ "path": "128.png", "frame": 10, "width": 424, - "height": 826 + "height": 826, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1698,7 +1720,9 @@ "path": "129.png", "frame": 11, "width": 264, - "height": 984 + "height": 984, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1709,7 +1733,9 @@ "path": "130.png", "frame": 12, "width": 698, - "height": 387 + "height": 387, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1720,7 +1746,9 @@ "path": "131.png", "frame": 13, "width": 781, - "height": 901 + "height": 901, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1731,7 +1759,9 @@ "path": "132.png", "frame": 14, "width": 144, - "height": 149 + "height": 149, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1742,7 +1772,9 @@ "path": "133.png", "frame": 15, "width": 989, - "height": 131 + "height": 131, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1753,7 +1785,9 @@ "path": "134.png", "frame": 16, "width": 661, - "height": 328 + "height": 328, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1764,7 +1798,9 @@ "path": "135.png", "frame": 17, "width": 333, - "height": 811 + "height": 811, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1775,7 +1811,9 @@ "path": "136.png", "frame": 18, "width": 292, - "height": 497 + "height": 497, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1786,7 +1824,9 @@ "path": "137.png", "frame": 19, "width": 886, - "height": 238 + "height": 238, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1797,7 +1837,9 @@ "path": "138.png", "frame": 20, "width": 759, - "height": 179 + "height": 179, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1808,7 +1850,9 @@ "path": "139.png", "frame": 21, "width": 769, - "height": 746 + "height": 746, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1819,7 +1863,9 @@ "path": "140.png", "frame": 22, "width": 749, - "height": 833 + "height": 833, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1830,7 +1876,9 @@ "path": "test_pointcloud_pcd/pointcloud/000001.pcd", "frame": 0, "width": 100, - "height": 1 + "height": 1, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1841,7 +1889,9 @@ "path": "0.png", "frame": 0, "width": 827, - "height": 983 + "height": 983, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1852,7 +1902,9 @@ "path": "1.png", "frame": 1, "width": 467, - "height": 547 + "height": 547, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1863,7 +1915,9 @@ "path": "10.png", "frame": 2, "width": 598, - "height": 202 + "height": 202, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1874,7 +1928,9 @@ "path": "2.png", "frame": 3, "width": 449, - "height": 276 + "height": 276, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1885,7 +1941,9 @@ "path": "3.png", "frame": 4, "width": 170, - "height": 999 + "height": 999, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1896,7 +1954,9 @@ "path": "4.png", "frame": 5, "width": 473, - "height": 471 + "height": 471, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1907,7 +1967,9 @@ "path": "5.png", "frame": 6, "width": 607, - "height": 745 + "height": 745, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1918,7 +1980,9 @@ "path": "6.png", "frame": 7, "width": 853, - "height": 578 + "height": 578, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1929,7 +1993,9 @@ "path": "7.png", "frame": 8, "width": 823, - "height": 270 + "height": 270, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1940,7 +2006,9 @@ "path": "8.png", "frame": 9, "width": 545, - "height": 179 + "height": 179, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1951,7 +2019,9 @@ "path": "9.png", "frame": 10, "width": 827, - "height": 932 + "height": 932, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1962,7 +2032,9 @@ "path": "0.png", "frame": 0, "width": 836, - "height": 636 + "height": 636, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1973,7 +2045,9 @@ "path": "1.png", "frame": 1, "width": 396, - "height": 350 + "height": 350, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1984,7 +2058,9 @@ "path": "10.png", "frame": 2, "width": 177, - "height": 862 + "height": 862, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -1995,7 +2071,9 @@ "path": "11.png", "frame": 3, "width": 318, - "height": 925 + "height": 925, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2006,7 +2084,9 @@ "path": "12.png", "frame": 4, "width": 734, - "height": 832 + "height": 832, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2017,7 +2097,9 @@ "path": "13.png", "frame": 5, "width": 925, - "height": 934 + "height": 934, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2028,7 +2110,9 @@ "path": "14.png", "frame": 6, "width": 851, - "height": 270 + "height": 270, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2039,7 +2123,9 @@ "path": "2.png", "frame": 7, "width": 776, - "height": 610 + "height": 610, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2050,7 +2136,9 @@ "path": "3.png", "frame": 8, "width": 293, - "height": 265 + "height": 265, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2061,7 +2149,9 @@ "path": "4.png", "frame": 9, "width": 333, - "height": 805 + "height": 805, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2072,7 +2162,9 @@ "path": "6.png", "frame": 10, "width": 403, - "height": 478 + "height": 478, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2083,7 +2175,9 @@ "path": "7.png", "frame": 11, "width": 585, - "height": 721 + "height": 721, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2094,7 +2188,9 @@ "path": "8.png", "frame": 12, "width": 639, - "height": 570 + "height": 570, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2105,7 +2201,9 @@ "path": "9.png", "frame": 13, "width": 894, - "height": 278 + "height": 278, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2116,7 +2214,9 @@ "path": "52.png", "frame": 0, "width": 220, - "height": 596 + "height": 596, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2127,7 +2227,9 @@ "path": "53.png", "frame": 1, "width": 749, - "height": 967 + "height": 967, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2138,7 +2240,9 @@ "path": "54.png", "frame": 2, "width": 961, - "height": 670 + "height": 670, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2149,7 +2253,9 @@ "path": "55.png", "frame": 3, "width": 393, - "height": 736 + "height": 736, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2160,7 +2266,9 @@ "path": "56.png", "frame": 4, "width": 650, - "height": 140 + "height": 140, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2171,7 +2279,9 @@ "path": "57.png", "frame": 5, "width": 199, - "height": 710 + "height": 710, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2182,7 +2292,9 @@ "path": "58.png", "frame": 6, "width": 948, - "height": 659 + "height": 659, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2193,7 +2305,9 @@ "path": "59.png", "frame": 7, "width": 837, - "height": 367 + "height": 367, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2204,7 +2318,9 @@ "path": "60.png", "frame": 8, "width": 257, - "height": 265 + "height": 265, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2215,7 +2331,9 @@ "path": "61.png", "frame": 9, "width": 104, - "height": 811 + "height": 811, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2226,7 +2344,9 @@ "path": "62.png", "frame": 10, "width": 665, - "height": 512 + "height": 512, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2237,7 +2357,9 @@ "path": "63.png", "frame": 11, "width": 234, - "height": 975 + "height": 975, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2248,7 +2370,9 @@ "path": "64.png", "frame": 12, "width": 809, - "height": 350 + "height": 350, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2259,7 +2383,9 @@ "path": "65.png", "frame": 13, "width": 359, - "height": 943 + "height": 943, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2270,7 +2396,9 @@ "path": "66.png", "frame": 14, "width": 782, - "height": 383 + "height": 383, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2281,7 +2409,9 @@ "path": "67.png", "frame": 15, "width": 571, - "height": 945 + "height": 945, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2292,7 +2422,9 @@ "path": "68.png", "frame": 16, "width": 414, - "height": 212 + "height": 212, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2303,7 +2435,9 @@ "path": "69.png", "frame": 17, "width": 680, - "height": 583 + "height": 583, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2314,7 +2448,9 @@ "path": "70.png", "frame": 18, "width": 779, - "height": 877 + "height": 877, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2325,7 +2461,9 @@ "path": "71.png", "frame": 19, "width": 411, - "height": 672 + "height": 672, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2336,7 +2474,9 @@ "path": "30.png", "frame": 0, "width": 810, - "height": 399 + "height": 399, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2347,7 +2487,9 @@ "path": "31.png", "frame": 1, "width": 916, - "height": 158 + "height": 158, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2358,7 +2500,9 @@ "path": "32.png", "frame": 2, "width": 936, - "height": 182 + "height": 182, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2369,7 +2513,9 @@ "path": "33.png", "frame": 3, "width": 783, - "height": 433 + "height": 433, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2380,7 +2526,9 @@ "path": "34.png", "frame": 4, "width": 231, - "height": 121 + "height": 121, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2391,7 +2539,9 @@ "path": "35.png", "frame": 5, "width": 721, - "height": 705 + "height": 705, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2402,7 +2552,9 @@ "path": "36.png", "frame": 6, "width": 631, - "height": 225 + "height": 225, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2413,7 +2565,9 @@ "path": "37.png", "frame": 7, "width": 540, - "height": 167 + "height": 167, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2424,7 +2578,9 @@ "path": "38.png", "frame": 8, "width": 203, - "height": 211 + "height": 211, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2435,7 +2591,9 @@ "path": "39.png", "frame": 9, "width": 677, - "height": 144 + "height": 144, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2446,7 +2604,9 @@ "path": "40.png", "frame": 10, "width": 697, - "height": 954 + "height": 954, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2457,7 +2617,9 @@ "path": "0.png", "frame": 0, "width": 974, - "height": 452 + "height": 452, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2468,7 +2630,9 @@ "path": "1.png", "frame": 1, "width": 783, - "height": 760 + "height": 760, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2479,7 +2643,9 @@ "path": "2.png", "frame": 2, "width": 528, - "height": 458 + "height": 458, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2490,7 +2656,9 @@ "path": "3.png", "frame": 3, "width": 520, - "height": 350 + "height": 350, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2501,7 +2669,9 @@ "path": "4.png", "frame": 4, "width": 569, - "height": 483 + "height": 483, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2512,7 +2682,9 @@ "path": "1.png", "frame": 0, "width": 783, - "height": 760 + "height": 760, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2523,7 +2695,9 @@ "path": "2.png", "frame": 1, "width": 528, - "height": 458 + "height": 458, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2534,7 +2708,9 @@ "path": "3.png", "frame": 2, "width": 520, - "height": 350 + "height": 350, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2545,7 +2721,9 @@ "path": "4.png", "frame": 3, "width": 569, - "height": 483 + "height": 483, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2556,7 +2734,9 @@ "path": "5.png", "frame": 4, "width": 514, - "height": 935 + "height": 935, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2567,7 +2747,9 @@ "path": "6.png", "frame": 5, "width": 502, - "height": 705 + "height": 705, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2578,7 +2760,9 @@ "path": "7.png", "frame": 6, "width": 541, - "height": 825 + "height": 825, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2589,7 +2773,9 @@ "path": "8.png", "frame": 7, "width": 883, - "height": 208 + "height": 208, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2600,7 +2786,9 @@ "path": "0.png", "frame": 0, "width": 974, - "height": 452 + "height": 452, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2611,7 +2799,9 @@ "path": "1.png", "frame": 1, "width": 783, - "height": 760 + "height": 760, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2622,7 +2812,9 @@ "path": "2.png", "frame": 2, "width": 528, - "height": 458 + "height": 458, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2633,7 +2825,9 @@ "path": "3.png", "frame": 3, "width": 520, - "height": 350 + "height": 350, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2644,7 +2838,9 @@ "path": "4.png", "frame": 4, "width": 569, - "height": 483 + "height": 483, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2655,7 +2851,9 @@ "path": "12.png", "frame": 0, "width": 607, - "height": 668 + "height": 668, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2666,7 +2864,9 @@ "path": "13.png", "frame": 1, "width": 483, - "height": 483 + "height": 483, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2677,7 +2877,9 @@ "path": "15.png", "frame": 0, "width": 982, - "height": 376 + "height": 376, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2688,7 +2890,9 @@ "path": "16.png", "frame": 1, "width": 565, - "height": 365 + "height": 365, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2699,7 +2903,9 @@ "path": "33.png", "frame": 0, "width": 339, - "height": 351 + "height": 351, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2710,7 +2916,9 @@ "path": "34.png", "frame": 1, "width": 944, - "height": 271 + "height": 271, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2721,7 +2929,9 @@ "path": "0.png", "frame": 0, "width": 865, - "height": 401 + "height": 401, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2732,7 +2942,9 @@ "path": "1.png", "frame": 1, "width": 912, - "height": 346 + "height": 346, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2743,7 +2955,9 @@ "path": "2.png", "frame": 2, "width": 681, - "height": 460 + "height": 460, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2754,7 +2968,9 @@ "path": "3.png", "frame": 3, "width": 844, - "height": 192 + "height": 192, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2765,7 +2981,9 @@ "path": "4.png", "frame": 4, "width": 462, - "height": 252 + "height": 252, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2776,7 +2994,9 @@ "path": "5.png", "frame": 5, "width": 191, - "height": 376 + "height": 376, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2787,7 +3007,9 @@ "path": "6.png", "frame": 6, "width": 333, - "height": 257 + "height": 257, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2798,7 +3020,9 @@ "path": "7.png", "frame": 7, "width": 474, - "height": 619 + "height": 619, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2809,7 +3033,9 @@ "path": "8.png", "frame": 8, "width": 809, - "height": 543 + "height": 543, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2820,7 +3046,9 @@ "path": "9.png", "frame": 9, "width": 993, - "height": 151 + "height": 151, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2831,7 +3059,9 @@ "path": "30.png", "frame": 0, "width": 810, - "height": 399 + "height": 399, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2842,7 +3072,9 @@ "path": "31.png", "frame": 1, "width": 916, - "height": 158 + "height": 158, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2853,7 +3085,9 @@ "path": "32.png", "frame": 2, "width": 936, - "height": 182 + "height": 182, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2864,7 +3098,9 @@ "path": "33.png", "frame": 3, "width": 783, - "height": 433 + "height": 433, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2875,7 +3111,9 @@ "path": "34.png", "frame": 4, "width": 231, - "height": 121 + "height": 121, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2886,7 +3124,9 @@ "path": "35.png", "frame": 5, "width": 721, - "height": 705 + "height": 705, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2897,7 +3137,9 @@ "path": "36.png", "frame": 6, "width": 631, - "height": 225 + "height": 225, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2908,7 +3150,9 @@ "path": "37.png", "frame": 7, "width": 540, - "height": 167 + "height": 167, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2919,7 +3163,9 @@ "path": "38.png", "frame": 8, "width": 203, - "height": 211 + "height": 211, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2930,7 +3176,9 @@ "path": "39.png", "frame": 9, "width": 677, - "height": 144 + "height": 144, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2941,7 +3189,9 @@ "path": "40.png", "frame": 10, "width": 697, - "height": 954 + "height": 954, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2952,7 +3202,9 @@ "path": "0.png", "frame": 0, "width": 549, - "height": 360 + "height": 360, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2963,7 +3215,9 @@ "path": "1.png", "frame": 1, "width": 172, - "height": 230 + "height": 230, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2974,7 +3228,9 @@ "path": "10.png", "frame": 2, "width": 936, - "height": 820 + "height": 820, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2985,7 +3241,9 @@ "path": "2.png", "frame": 3, "width": 145, - "height": 735 + "height": 735, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -2996,7 +3254,9 @@ "path": "3.png", "frame": 4, "width": 318, - "height": 729 + "height": 729, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -3007,7 +3267,9 @@ "path": "4.png", "frame": 5, "width": 387, - "height": 168 + "height": 168, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -3018,7 +3280,9 @@ "path": "5.png", "frame": 6, "width": 395, - "height": 401 + "height": 401, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -3029,7 +3293,9 @@ "path": "6.png", "frame": 7, "width": 293, - "height": 443 + "height": 443, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -3040,7 +3306,9 @@ "path": "7.png", "frame": 8, "width": 500, - "height": 276 + "height": 276, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -3051,7 +3319,9 @@ "path": "8.png", "frame": 9, "width": 309, - "height": 162 + "height": 162, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -3062,7 +3332,9 @@ "path": "9.png", "frame": 10, "width": 134, - "height": 452 + "height": 452, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -3073,7 +3345,9 @@ "path": "img.png", "frame": 0, "width": 10, - "height": 10 + "height": 10, + "is_placeholder": false, + "real_frame_id": 0 } }, { @@ -3084,7 +3358,9 @@ "path": "img.png", "frame": 0, "width": 10, - "height": 10 + "height": 10, + "is_placeholder": false, + "real_frame_id": 0 } }, { diff --git a/tests/python/shared/assets/tasks.json b/tests/python/shared/assets/tasks.json index a7219503004b..7adbec364215 100644 --- a/tests/python/shared/assets/tasks.json +++ b/tests/python/shared/assets/tasks.json @@ -52,7 +52,8 @@ "location": "local" }, "updated_date": "2024-07-15T15:34:53.692000Z", - "url": "http://localhost:8080/api/tasks/25" + "url": "http://localhost:8080/api/tasks/25", + "validation_mode": null }, { "assignee": null, @@ -103,7 +104,8 @@ "location": "local" }, "updated_date": "2024-07-15T15:33:10.641000Z", - "url": "http://localhost:8080/api/tasks/24" + "url": "http://localhost:8080/api/tasks/24", + "validation_mode": null }, { "assignee": null, @@ -146,7 +148,8 @@ "subset": "", "target_storage": null, "updated_date": "2024-03-21T20:50:05.947000Z", - "url": "http://localhost:8080/api/tasks/23" + "url": "http://localhost:8080/api/tasks/23", + "validation_mode": null }, { "assignee": null, @@ -189,7 +192,8 @@ "subset": "Train", "target_storage": null, "updated_date": "2023-11-24T15:23:30.045000Z", - "url": "http://localhost:8080/api/tasks/22" + "url": "http://localhost:8080/api/tasks/22", + "validation_mode": null }, { "assignee": null, @@ -240,7 +244,8 @@ "location": "local" }, "updated_date": "2023-03-27T19:08:40.032000Z", - "url": "http://localhost:8080/api/tasks/21" + "url": "http://localhost:8080/api/tasks/21", + "validation_mode": null }, { "assignee": null, @@ -291,7 +296,8 @@ "location": "local" }, "updated_date": "2023-03-10T11:57:48.835000Z", - "url": "http://localhost:8080/api/tasks/20" + "url": "http://localhost:8080/api/tasks/20", + "validation_mode": null }, { "assignee": null, @@ -342,7 +348,8 @@ "location": "local" }, "updated_date": "2023-03-10T11:56:54.904000Z", - "url": "http://localhost:8080/api/tasks/19" + "url": "http://localhost:8080/api/tasks/19", + "validation_mode": null }, { "assignee": null, @@ -393,7 +400,8 @@ "location": "local" }, "updated_date": "2023-03-01T15:36:37.897000Z", - "url": "http://localhost:8080/api/tasks/18" + "url": "http://localhost:8080/api/tasks/18", + "validation_mode": null }, { "assignee": { @@ -442,7 +450,8 @@ "subset": "", "target_storage": null, "updated_date": "2023-02-10T14:08:05.873000Z", - "url": "http://localhost:8080/api/tasks/17" + "url": "http://localhost:8080/api/tasks/17", + "validation_mode": null }, { "assignee": null, @@ -493,7 +502,8 @@ "location": "local" }, "updated_date": "2022-12-01T12:53:35.028000Z", - "url": "http://localhost:8080/api/tasks/15" + "url": "http://localhost:8080/api/tasks/15", + "validation_mode": null }, { "assignee": null, @@ -544,7 +554,8 @@ "location": "local" }, "updated_date": "2022-09-23T11:57:02.300000Z", - "url": "http://localhost:8080/api/tasks/14" + "url": "http://localhost:8080/api/tasks/14", + "validation_mode": null }, { "assignee": { @@ -593,7 +604,8 @@ "subset": "", "target_storage": null, "updated_date": "2023-02-10T11:50:18.414000Z", - "url": "http://localhost:8080/api/tasks/13" + "url": "http://localhost:8080/api/tasks/13", + "validation_mode": null }, { "assignee": null, @@ -630,7 +642,8 @@ "subset": "", "target_storage": null, "updated_date": "2022-03-14T13:24:05.861000Z", - "url": "http://localhost:8080/api/tasks/12" + "url": "http://localhost:8080/api/tasks/12", + "validation_mode": null }, { "assignee": { @@ -687,7 +700,8 @@ "location": "cloud_storage" }, "updated_date": "2022-06-30T08:56:45.594000Z", - "url": "http://localhost:8080/api/tasks/11" + "url": "http://localhost:8080/api/tasks/11", + "validation_mode": null }, { "assignee": { @@ -736,7 +750,8 @@ "subset": "", "target_storage": null, "updated_date": "2022-11-03T13:57:26.007000Z", - "url": "http://localhost:8080/api/tasks/9" + "url": "http://localhost:8080/api/tasks/9", + "validation_mode": null }, { "assignee": { @@ -785,7 +800,8 @@ "subset": "", "target_storage": null, "updated_date": "2023-05-02T09:28:57.638000Z", - "url": "http://localhost:8080/api/tasks/8" + "url": "http://localhost:8080/api/tasks/8", + "validation_mode": null }, { "assignee": { @@ -834,7 +850,8 @@ "subset": "", "target_storage": null, "updated_date": "2022-02-21T10:41:38.540000Z", - "url": "http://localhost:8080/api/tasks/7" + "url": "http://localhost:8080/api/tasks/7", + "validation_mode": null }, { "assignee": null, @@ -877,7 +894,8 @@ "subset": "", "target_storage": null, "updated_date": "2022-02-16T06:26:54.836000Z", - "url": "http://localhost:8080/api/tasks/6" + "url": "http://localhost:8080/api/tasks/6", + "validation_mode": null }, { "assignee": { @@ -926,7 +944,8 @@ "subset": "", "target_storage": null, "updated_date": "2022-02-21T10:40:21.257000Z", - "url": "http://localhost:8080/api/tasks/5" + "url": "http://localhost:8080/api/tasks/5", + "validation_mode": null }, { "assignee": { @@ -975,7 +994,8 @@ "subset": "", "target_storage": null, "updated_date": "2021-12-22T07:14:15.234000Z", - "url": "http://localhost:8080/api/tasks/2" + "url": "http://localhost:8080/api/tasks/2", + "validation_mode": null } ] } diff --git a/tests/python/shared/fixtures/init.py b/tests/python/shared/fixtures/init.py index 99f1f02f8e0b..bb72ba9ef259 100644 --- a/tests/python/shared/fixtures/init.py +++ b/tests/python/shared/fixtures/init.py @@ -568,6 +568,15 @@ def restore_cvat_data_per_function(request): kube_restore_data_volumes() +@pytest.fixture(scope="class") +def restore_cvat_data_per_class(request): + platform = request.config.getoption("--platform") + if platform == "local": + docker_restore_data_volumes() + else: + kube_restore_data_volumes() + + @pytest.fixture(scope="function") def restore_clickhouse_db_per_function(request): # Note that autouse fixtures are executed first within their scope, so be aware of the order From c87de20359c5b277afbff738e657f302e04ce8dc Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 11 Sep 2024 18:50:21 +0300 Subject: [PATCH 137/227] Fix linter errors --- cvat/apps/dataset_manager/annotation.py | 2 +- cvat/apps/engine/field_validation.py | 2 +- cvat/apps/engine/serializers.py | 2 +- cvat/apps/engine/task.py | 1 - cvat/apps/engine/utils.py | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cvat/apps/dataset_manager/annotation.py b/cvat/apps/dataset_manager/annotation.py index 2f4a4ec91f69..82d78fe7f050 100644 --- a/cvat/apps/dataset_manager/annotation.py +++ b/cvat/apps/dataset_manager/annotation.py @@ -217,7 +217,7 @@ def to_shapes(self, def to_tracks(self): tracks = self.data.tracks - shapes = ShapeManager(self.data.shapes) + shapes = ShapeManager(self.data.shapes, dimension=self.dimension) return tracks + shapes.to_tracks() diff --git a/cvat/apps/engine/field_validation.py b/cvat/apps/engine/field_validation.py index 065a0a0cd422..02b2d90a3f1b 100644 --- a/cvat/apps/engine/field_validation.py +++ b/cvat/apps/engine/field_validation.py @@ -39,7 +39,7 @@ def require_one_of_values(data: dict[str, Any], key: str, values: Sequence[Any]) def validate_percent(value: float) -> float: - if not (0 <= value <= 1): + if not 0 <= value <= 1: raise serializers.ValidationError("Value must be in the range [0; 1]") return value diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 902273971275..e5da1f110a5a 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -14,7 +14,7 @@ from tempfile import NamedTemporaryFile import textwrap -from typing import Any, Dict, Iterable, Optional, OrderedDict, Sequence, Union +from typing import Any, Dict, Iterable, Optional, OrderedDict, Union from rq.job import Job as RQJob, JobStatus as RQJobStatus from datetime import timedelta diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 6fc81958308e..f76d432f70de 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -12,7 +12,6 @@ import shutil from copy import deepcopy from rest_framework.serializers import ValidationError -from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Sequence, Union, Iterable from contextlib import closing from datetime import datetime, timezone from pathlib import Path diff --git a/cvat/apps/engine/utils.py b/cvat/apps/engine/utils.py index fa91e6e6b6a6..256094837598 100644 --- a/cvat/apps/engine/utils.py +++ b/cvat/apps/engine/utils.py @@ -438,4 +438,4 @@ def format_list( return "{}{}".format( separator.join(items[:max_items]), f" (and {remainder_count} more)" if 0 < remainder_count else "", - ) \ No newline at end of file + ) From f9f500193132c05e0e9c46efa811a146bc179986 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 12 Sep 2024 15:45:47 +0300 Subject: [PATCH 138/227] Add task creation tests --- cvat/apps/engine/serializers.py | 5 + cvat/apps/engine/task.py | 8 +- tests/python/rest_api/test_tasks.py | 318 ++++++++++++++++++++++++---- 3 files changed, 293 insertions(+), 38 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index e5da1f110a5a..39c1c2f175b3 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1155,6 +1155,11 @@ def validate(self, attrs): ) ) + if attrs.get('frames'): + unique_frames = set(attrs['frames']) + if len(unique_frames) != len(attrs['frames']): + raise serializers.ValidationError("Frames must not repeat") + return super().validate(attrs) @transaction.atomic diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index f76d432f70de..a8562336e9cb 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1322,7 +1322,7 @@ def _update_status(msg: str) -> None: validation_params = { "mode": models.ValidationMode.GT_POOL, "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, - "frames": [new_db_images[frame_id].path for frame_id in validation_frames], + "frames": [images[frame_id].path for frame_id in validation_frames], "frames_per_job_count": frames_per_job_count, # reset other fields @@ -1409,6 +1409,8 @@ def _update_status(msg: str) -> None: list(segment.frame_set), size=frame_count, shuffle=False, replace=False ).tolist()) case models.JobFrameSelectionMethod.MANUAL: + # TODO: support video tasks and other sequences with unknown file names + known_frame_names = {frame.path: frame.frame for frame in images} unknown_requested_frames = [] for frame in db_data.validation_layout.frames.all(): @@ -1428,13 +1430,15 @@ def _update_status(msg: str) -> None: f'Unknown frame selection method {validation_params["frame_selection_method"]}' ) + validation_frames = sorted(validation_frames) + # Save the created validation layout # TODO: try to find a way to avoid using the same model for storing the user request # and internal data validation_params = { "mode": models.ValidationMode.GT, "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, - "frames": [new_db_images[frame_id].path for frame_id in validation_frames], + "frames": [images[frame_id].path for frame_id in validation_frames], # reset other fields "random_seed": None, diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index a130f10d4afc..49c61a4e3ea1 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -11,6 +11,7 @@ import os.path as osp import zipfile from abc import ABCMeta, abstractmethod +from collections import Counter from contextlib import closing from copy import deepcopy from enum import Enum @@ -2035,6 +2036,250 @@ def test_create_task_with_cloud_storage_directories_and_default_bucket_prefix( assert response.status == HTTPStatus.OK assert task.size == expected_task_size + @parametrize( + "frame_selection_method, method_params, per_job_count_param", + map( + lambda e: (*e[0], e[1]), + product( + [ + *tuple(product(["random_uniform"], [{"frame_count"}, {"frame_share"}])), + ("manual", {}), + ], + ["frames_per_job_count", "frames_per_job_share"], + ), + ), + ) + def test_can_create_task_with_honeypots( + self, + request: pytest.FixtureRequest, + frame_selection_method: str, + method_params: set[str], + per_job_count_param: str, + ): + base_segment_size = 4 + total_frame_count = 15 + validation_frames_count = 5 + validation_per_job_count = 2 + regular_frame_count = total_frame_count - validation_frames_count + resulting_task_size = ( + regular_frame_count + + validation_per_job_count * math.ceil(regular_frame_count / base_segment_size) + + validation_frames_count + ) + + image_files = generate_image_files(total_frame_count) + + validation_params = {"mode": "gt_pool", "frame_selection_method": frame_selection_method} + + if per_job_count_param == "frames_per_job_count": + validation_params[per_job_count_param] = validation_per_job_count + elif per_job_count_param == "frames_per_job_share": + validation_params[per_job_count_param] = validation_per_job_count / base_segment_size + else: + assert False + + if frame_selection_method == "random_uniform": + validation_params["random_seed"] = 42 + + for method_param in method_params: + if method_param == "frame_count": + validation_params[method_param] = validation_frames_count + elif method_param == "frame_share": + validation_params[method_param] = validation_frames_count / total_frame_count + else: + assert False + elif frame_selection_method == "manual": + rng = np.random.Generator(np.random.MT19937(seed=42)) + validation_params["frames"] = rng.choice( + [f.name for f in image_files], validation_frames_count, replace=False + ).tolist() + else: + assert False + + task_params = { + "name": request.node.name, + "labels": [{"name": "a"}], + "segment_size": base_segment_size, + } + + data_params = { + "image_quality": 70, + "client_files": image_files, + "sorting_method": "random", + "validation_params": validation_params, + } + + task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params) + + with make_api_client(self._USERNAME) as api_client: + (task, _) = api_client.tasks_api.retrieve(task_id) + (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) + annotation_job_metas = [ + api_client.jobs_api.retrieve_data_meta(job.id)[0] + for job in get_paginated_collection( + api_client.jobs_api.list_endpoint, task_id=task_id, type="annotation" + ) + ] + gt_job_metas = [ + api_client.jobs_api.retrieve_data_meta(job.id)[0] + for job in get_paginated_collection( + api_client.jobs_api.list_endpoint, task_id=task_id, type="ground_truth" + ) + ] + + assert len(gt_job_metas) == 1 + + assert task.segment_size == 0 # means "custom segments" + assert task.size == resulting_task_size + assert task_meta.size == resulting_task_size + + # validation frames (pool frames) must be appended in the end of the task, in the GT job + validation_frames = set(f.name for f in task_meta.frames[-validation_frames_count:]) + if frame_selection_method == "manual": + assert sorted(validation_frames) == sorted(validation_params["frames"]) + assert sorted(f.name for f in gt_job_metas[0].frames) == sorted( + validation_params["frames"] + ) + + annotation_job_frame_counts = Counter( + f.name for f in task_meta.frames[:-validation_frames_count] + ) + + regular_frame_counts = { + k: v for k, v in annotation_job_frame_counts.items() if k not in validation_frames + } + # regular frames must not repeat + assert regular_frame_counts == { + f.name: 1 for f in image_files if f.name not in validation_frames + } + + # only validation frames can repeat + assert set(fn for fn, count in annotation_job_frame_counts.items() if count != 1).issubset( + validation_frames + ) + + # each job must have the specified number of validation frames + for job_meta in annotation_job_metas: + assert ( + len(set(f.name for f in job_meta.frames if f.name in validation_frames)) + == validation_per_job_count + ) + + @parametrize( + "frame_selection_method, method_params", + [ + *tuple(product(["random_uniform"], [{"frame_count"}, {"frame_share"}])), + *tuple( + product(["random_per_job"], [{"frames_per_job_count"}, {"frames_per_job_share"}]) + ), + ("manual", {}), + ], + ) + def test_can_create_task_with_gt_job( + self, + request: pytest.FixtureRequest, + frame_selection_method: str, + method_params: set[str], + ): + segment_size = 4 + total_frame_count = 15 + validation_frames_count = 5 + resulting_task_size = total_frame_count + + image_files = generate_image_files(total_frame_count) + + validation_params = {"mode": "gt", "frame_selection_method": frame_selection_method} + + if "random" in frame_selection_method: + validation_params["random_seed"] = 42 + + if frame_selection_method == "random_uniform": + for method_param in method_params: + if method_param == "frame_count": + validation_params[method_param] = validation_frames_count + elif method_param == "frame_share": + validation_params[method_param] = validation_frames_count / total_frame_count + else: + assert False + elif frame_selection_method == "random_per_job": + validation_per_job_count = 2 + + for method_param in method_params: + if method_param == "frames_per_job_count": + validation_params[method_param] = validation_per_job_count + elif method_param == "frames_per_job_share": + validation_params[method_param] = validation_per_job_count / segment_size + else: + assert False + elif frame_selection_method == "manual": + rng = np.random.Generator(np.random.MT19937(seed=42)) + validation_params["frames"] = rng.choice( + [f.name for f in image_files], validation_frames_count, replace=False + ).tolist() + else: + assert False + + task_params = { + "name": request.node.name, + "labels": [{"name": "a"}], + "segment_size": segment_size, + } + + data_params = { + "image_quality": 70, + "client_files": image_files, + "validation_params": validation_params, + } + + task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params) + + with make_api_client(self._USERNAME) as api_client: + (task, _) = api_client.tasks_api.retrieve(task_id) + (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) + annotation_job_metas = [ + api_client.jobs_api.retrieve_data_meta(job.id)[0] + for job in get_paginated_collection( + api_client.jobs_api.list_endpoint, task_id=task_id, type="annotation" + ) + ] + gt_job_metas = [ + api_client.jobs_api.retrieve_data_meta(job.id)[0] + for job in get_paginated_collection( + api_client.jobs_api.list_endpoint, task_id=task_id, type="ground_truth" + ) + ] + + assert len(gt_job_metas) == 1 + + assert task.segment_size == segment_size + assert task.size == resulting_task_size + assert task_meta.size == resulting_task_size + + validation_frames = set(f.name for f in gt_job_metas[0].frames) + if frame_selection_method == "manual": + assert sorted( + gt_job_metas[0].frames[rel_frame_id].name + for rel_frame_id, abs_frame_id in enumerate( + range( + gt_job_metas[0].start_frame, + gt_job_metas[0].stop_frame + 1, + int((gt_job_metas[0].frame_filter or "step=1").split("=")[1]), + ) + ) + if abs_frame_id in gt_job_metas[0].included_frames + ) == sorted(validation_params["frames"]) + + # frames must not repeat + assert sorted(f.name for f in image_files) == sorted(f.name for f in task_meta.frames) + + if frame_selection_method == "random_per_job": + # each job must have the specified number of validation frames + for job_meta in annotation_job_metas: + assert ( + len(set(f.name for f in job_meta.frames if f.name in validation_frames)) + == validation_per_job_count + ) + class _SourceDataType(str, Enum): images = "images" @@ -2110,12 +2355,14 @@ def _uploaded_images_task_fxt_base( self, request: pytest.FixtureRequest, *, - frame_count: int = 10, + frame_count: Optional[int] = 10, + image_files: Optional[Sequence[io.BytesIO]] = None, start_frame: Optional[int] = None, stop_frame: Optional[int] = None, step: Optional[int] = None, segment_size: Optional[int] = None, - ) -> Generator[Tuple[_TaskSpec, int], None, None]: + **data_kwargs, + ) -> Generator[Tuple[_ImagesTaskSpec, int], None, None]: task_params = { "name": request.node.name, "labels": [{"name": "a"}], @@ -2123,13 +2370,21 @@ def _uploaded_images_task_fxt_base( if segment_size: task_params["segment_size"] = segment_size - image_files = generate_image_files(frame_count) + assert bool(image_files) ^ bool( + frame_count + ), "Expected only one of 'image_files' and 'frame_count'" + if not image_files: + image_files = generate_image_files(frame_count) + elif not frame_count: + frame_count = len(image_files) + images_data = [f.getvalue() for f in image_files] data_params = { "image_quality": 70, "client_files": image_files, "sorting_method": "natural", } + data_params.update(data_kwargs) if start_frame is not None: data_params["start_frame"] = start_frame @@ -2202,43 +2457,34 @@ def fxt_uploaded_images_task_with_segments_and_honeypots( + validation_params.frame_count ) - task_params = { - "name": request.node.name, - "labels": [{"name": "a"}], - "segment_size": base_segment_size, - } - image_files = generate_image_files(total_frame_count) - images_data = [f.getvalue() for f in image_files] - data_params = { - "image_quality": 70, - "client_files": image_files, - "sorting_method": "random", - "validation_params": validation_params, - } - - task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params) - - with make_api_client(self._USERNAME) as api_client: - (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) - frame_map = [ - next(i for i, f in enumerate(image_files) if f.name == frame_info.name) - for frame_info in task_meta.frames - ] - def get_frame(i: int) -> bytes: - return images_data[frame_map[i]] + with closing( + self._uploaded_images_task_fxt_base( + request=request, + frame_count=None, + image_files=image_files, + segment_size=base_segment_size, + sorting_method="random", + validation_params=validation_params, + ) + ) as task_gen: + for task_spec, task_id in task_gen: + # Get the actual frame order after the task is created + with make_api_client(self._USERNAME) as api_client: + (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) + frame_map = [ + next(i for i, f in enumerate(image_files) if f.name == frame_info.name) + for frame_info in task_meta.frames + ] - task_spec = _ImagesTaskSpec( - models.TaskWriteRequest._from_openapi_data(**task_params), - models.DataRequest._from_openapi_data(**data_params), - get_frame=get_frame, - size=final_task_size, - ) + _get_frame = task_spec._get_frame + task_spec._get_frame = lambda i: _get_frame(frame_map[i]) - task_spec._params.segment_size = final_segment_size + task_spec.size = final_task_size + task_spec._params.segment_size = final_segment_size - yield task_spec, task_id + yield task_spec, task_id def _uploaded_video_task_fxt_base( self, @@ -2246,7 +2492,7 @@ def _uploaded_video_task_fxt_base( *, frame_count: int = 10, segment_size: Optional[int] = None, - ) -> Generator[Tuple[_TaskSpec, int], None, None]: + ) -> Generator[Tuple[_VideoTaskSpec, int], None, None]: task_params = { "name": request.node.name, "labels": [{"name": "a"}], From 79bb1f74be75bbb3485c8f772b0e1cf3cae540af Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 12 Sep 2024 16:14:36 +0300 Subject: [PATCH 139/227] Remove array comparisons --- cvat-core/src/frames.ts | 2 ++ cvat-data/src/ts/cvat-data.ts | 36 ++++++++++++++++------------------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index bcba1899acb1..47739b7e5ca6 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -392,6 +392,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { provider.cleanup(1); provider.requestDecodeBlock( chunk, + nextChunkNumber, segmentFrameNumbers.slice( nextChunkNumber * chunkSize, (nextChunkNumber + 1) * chunkSize, @@ -456,6 +457,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { provider .requestDecodeBlock( chunk, + chunkNumber, segmentFrameNumbers.slice( chunkNumber * chunkSize, (chunkNumber + 1) * chunkSize, diff --git a/cvat-data/src/ts/cvat-data.ts b/cvat-data/src/ts/cvat-data.ts index 05aa359dc453..5b9e9286a2f3 100644 --- a/cvat-data/src/ts/cvat-data.ts +++ b/cvat-data/src/ts/cvat-data.ts @@ -72,7 +72,7 @@ export function decodeContextImages( decodeContextImages.mutex = new Mutex(); interface BlockToDecode { - frameNumbers: number[]; + chunkFrameNumbers: number[]; chunkNumber: number; block: ArrayBuffer; onDecodeAll(): void; @@ -173,25 +173,19 @@ export class FrameDecoder { } } - private arraysEqual(a: number[], b: number[]): boolean { - return ( - a.length === b.length && - a.every((element, index) => element === b[index]) - ); - } - requestDecodeBlock( block: ArrayBuffer, - frameNumbers: number[], + chunkNumber: number, + chunkFrameNumbers: number[], onDecode: (frame: number, bitmap: ImageBitmap | Blob) => void, onDecodeAll: () => void, onReject: (e: Error) => void, ): void { - this.validateFrameNumbers(frameNumbers); + this.validateFrameNumbers(chunkFrameNumbers); if (this.requestedChunkToDecode !== null) { // a chunk was already requested to be decoded, but decoding didn't start yet - if (this.arraysEqual(frameNumbers, this.requestedChunkToDecode.frameNumbers)) { + if (chunkNumber === this.requestedChunkToDecode.chunkNumber) { // it was the same chunk this.requestedChunkToDecode.onReject(new RequestOutdatedError()); @@ -202,12 +196,12 @@ export class FrameDecoder { this.requestedChunkToDecode.onReject(new RequestOutdatedError()); } } else if (this.chunkIsBeingDecoded === null || - !this.arraysEqual(frameNumbers, this.requestedChunkToDecode.frameNumbers) + chunkNumber !== this.chunkIsBeingDecoded.chunkNumber ) { // everything was decoded or decoding other chunk is in process this.requestedChunkToDecode = { - frameNumbers, - chunkNumber: this.getChunkNumber(frameNumbers[0]), + chunkFrameNumbers, + chunkNumber, block, onDecode, onDecodeAll, @@ -281,8 +275,8 @@ export class FrameDecoder { releaseMutex(); }; try { - const { frameNumbers, chunkNumber, block } = this.requestedChunkToDecode; - if (!this.arraysEqual(frameNumbers, blockToDecode.frameNumbers)) { + const { chunkFrameNumbers, chunkNumber, block } = this.requestedChunkToDecode; + if (chunkNumber !== blockToDecode.chunkNumber) { // request is not relevant, another block was already requested // it happens when A is being decoded, B comes and wait for mutex, C comes and wait for mutex // B is not necessary anymore, because C already was requested @@ -290,7 +284,9 @@ export class FrameDecoder { throw new RequestOutdatedError(); } - const getFrameNumber = (chunkFrameIndex: number): number => frameNumbers[chunkFrameIndex]; + const getFrameNumber = (chunkFrameIndex: number): number => ( + chunkFrameNumbers[chunkFrameIndex] + ); this.orderedStack = [chunkNumber, ...this.orderedStack]; this.cleanup(); @@ -328,7 +324,7 @@ export class FrameDecoder { decodedFrames[frameNumber] = bitmap; this.chunkIsBeingDecoded.onDecode(frameNumber, decodedFrames[frameNumber]); - if (keptIndex === frameNumbers.length - 1) { + if (keptIndex === chunkFrameNumbers.length - 1) { this.decodedChunks[chunkNumber] = decodedFrames; this.chunkIsBeingDecoded.onDecodeAll(); this.chunkIsBeingDecoded = null; @@ -387,7 +383,7 @@ export class FrameDecoder { decodedFrames[frameNumber] = event.data.data as ImageBitmap | Blob; this.chunkIsBeingDecoded.onDecode(frameNumber, decodedFrames[frameNumber]); - if (decodedCount === frameNumbers.length - 1) { + if (decodedCount === chunkFrameNumbers.length - 1) { this.decodedChunks[chunkNumber] = decodedFrames; this.chunkIsBeingDecoded.onDecodeAll(); this.chunkIsBeingDecoded = null; @@ -406,7 +402,7 @@ export class FrameDecoder { this.zipWorker.postMessage({ block, start: 0, - end: frameNumbers.length - 1, + end: chunkFrameNumbers.length - 1, dimension: this.dimension, dimension2D: DimensionType.DIMENSION_2D, }); From 55a8424ccb1db668308e536670c6b845a3b701a2 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 12 Sep 2024 16:17:26 +0300 Subject: [PATCH 140/227] Update validateFrameNumbers --- cvat-data/src/ts/cvat-data.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cvat-data/src/ts/cvat-data.ts b/cvat-data/src/ts/cvat-data.ts index 5b9e9286a2f3..97e796af9df7 100644 --- a/cvat-data/src/ts/cvat-data.ts +++ b/cvat-data/src/ts/cvat-data.ts @@ -156,8 +156,8 @@ export class FrameDecoder { } private validateFrameNumbers(frameNumbers: number[]): void { - if (!frameNumbers || !frameNumbers.length) { - throw new Error('frameNumbers must not be empty'); + if (!Array.isArray(frameNumbers) || !frameNumbers.length) { + throw new Error('chunkFrameNumbers must not be empty'); } // ensure is ordered @@ -166,7 +166,7 @@ export class FrameDecoder { const current = frameNumbers[i]; if (current <= prev) { throw new Error( - 'frameNumbers must be sorted in ascending order, ' + + 'chunkFrameNumbers must be sorted in the ascending order, ' + `got a (${prev}, ${current}) pair instead`, ); } From add5ae6e5cfe9375666d04d284d26611f214288b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 12 Sep 2024 16:51:36 +0300 Subject: [PATCH 141/227] Use builtins for range and binary search, convert frame step into a cached field --- cvat-core/src/frames.ts | 58 +++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index 47739b7e5ca6..02cc63bee54d 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -3,7 +3,7 @@ // // SPDX-License-Identifier: MIT -import _ from 'lodash'; +import _, { range, sortedIndexOf } from 'lodash'; import { FrameDecoder, BlockType, DimensionType, ChunkQuality, decodeContextImages, RequestOutdatedError, } from 'cvat-data'; @@ -40,13 +40,6 @@ const frameDataCache: Record> = {}; -function rangeArray(start: number, end: number, step: number = 1): number[] { - return Array.from( - { length: +(start < end) * Math.ceil((end - start) / step) }, - (v, k) => k * step + start, - ); -} - export class FramesMetaData { public chunkSize: number; public deletedFrames: Record; @@ -62,6 +55,7 @@ export class FramesMetaData { public size: number; public startFrame: number; public stopFrame: number; + public frameStep: number; #updateTrigger: FieldUpdateTrigger; @@ -110,6 +104,17 @@ export class FramesMetaData { } } + const frameStep: number = (() => { + if (data.frame_filter) { + const frameStepParts = data.frame_filter.split('=', 2); + if (frameStepParts.length !== 2) { + throw new ArgumentError(`Invalid frame filter '${data.frame_filter}'`); + } + return +frameStepParts[1]; + } + return 1; + })(); + Object.defineProperties( this, Object.freeze({ @@ -140,6 +145,9 @@ export class FramesMetaData { stopFrame: { get: () => data.stop_frame, }, + frameStep: { + get: () => frameStep, + }, }), ); } @@ -153,7 +161,10 @@ export class FramesMetaData { } getFrameIndex(dataFrameNumber: number): number { - // TODO: migrate to local frame numbers to simplify code + // Here we use absolute (task source data) frame numbers. + // TODO: migrate from data frame numbers to local frame numbers to simplify code. + // Requires server changes in api/jobs/{id}/data/meta/ + // for included_frames, start_frame, stop_frame fields if (dataFrameNumber < this.startFrame || dataFrameNumber > this.stopFrame) { throw new ArgumentError(`Frame number ${dataFrameNumber} doesn't belong to the job`); @@ -161,12 +172,12 @@ export class FramesMetaData { let frameIndex = null; if (this.includedFrames) { - frameIndex = this.includedFrames.indexOf(dataFrameNumber); // TODO: use binary search + frameIndex = sortedIndexOf(this.includedFrames, dataFrameNumber); if (frameIndex === -1) { throw new ArgumentError(`Frame number ${dataFrameNumber} doesn't belong to the job`); } } else { - frameIndex = Math.floor((dataFrameNumber - this.startFrame) / this.getFrameStep()); + frameIndex = Math.floor((dataFrameNumber - this.startFrame) / this.frameStep); } return frameIndex; } @@ -175,23 +186,12 @@ export class FramesMetaData { return Math.floor(this.getFrameIndex(dataFrameNumber) / this.chunkSize); } - getFrameStep(): number { - if (this.frameFilter) { - const frameStepParts = this.frameFilter.split('=', 2); - if (frameStepParts.length !== 2) { - throw new Error(`Invalid frame filter '${this.frameFilter}'`); - } - return parseInt(frameStepParts[1], 10); - } - return 1; - } - getDataFrameNumbers(): number[] { if (this.includedFrames) { return this.includedFrames; } - return rangeArray(this.startFrame, this.stopFrame + 1, this.getFrameStep()); + return range(this.startFrame, this.stopFrame + 1, this.frameStep); } } @@ -309,7 +309,7 @@ class PrefetchAnalyzer { } function getDataStartFrame(meta: FramesMetaData, localStartFrame: number): number { - return meta.startFrame - localStartFrame * meta.getFrameStep(); + return meta.startFrame - localStartFrame * meta.frameStep; } function getDataFrameNumber(frameNumber: number, dataStartFrame: number, step: number): number { @@ -335,11 +335,13 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { const requestId = +_.uniqueId(); const dataStartFrame = getDataStartFrame(meta, startFrame); const requestedDataFrameNumber = getDataFrameNumber( - this.number, dataStartFrame, meta.getFrameStep(), + this.number, dataStartFrame, meta.frameStep, ); const chunkNumber = meta.getFrameChunkIndex(requestedDataFrameNumber); const segmentFrameNumbers = meta.getDataFrameNumbers().map( - (dataFrameNumber: number) => getFrameNumber(dataFrameNumber, dataStartFrame, meta.getFrameStep()), + (dataFrameNumber: number) => getFrameNumber( + dataFrameNumber, dataStartFrame, meta.frameStep, + ), ); const frame = provider.frame(this.number); @@ -686,7 +688,7 @@ export async function getFrame( // TODO: migrate to local frame numbers const dataStartFrame = getDataStartFrame(meta, startFrame); const dataFrameNumberGetter = (frameNumber: number): number => ( - getDataFrameNumber(frameNumber, dataStartFrame, meta.getFrameStep()) + getDataFrameNumber(frameNumber, dataStartFrame, meta.frameStep) ); frameDataCache[jobID] = { @@ -782,7 +784,7 @@ export async function findFrame( // meta.includedFrames contains input frame numbers now const dataStartFrame = meta.startFrame; // this is only true when includedFrames is set return (meta.includedFrames.includes( - getDataFrameNumber(frame, dataStartFrame, meta.getFrameStep())) + getDataFrameNumber(frame, dataStartFrame, meta.frameStep)) ) && (!filters.notDeleted || !(frame in meta.deletedFrames)); } if (filters.notDeleted) { From 27f733ab7a33f47e77e621b21d3f4c2320921f63 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 12 Sep 2024 17:26:45 +0300 Subject: [PATCH 142/227] Disallow manual validation frame selection for gt job when task is created from video --- cvat/apps/engine/task.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index a8562336e9cb..3e6adf9724a2 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1409,7 +1409,14 @@ def _update_status(msg: str) -> None: list(segment.frame_set), size=frame_count, shuffle=False, replace=False ).tolist()) case models.JobFrameSelectionMethod.MANUAL: - # TODO: support video tasks and other sequences with unknown file names + if not images: + raise ValidationError( + "{} validation frame selection method at task creation " + "is only available for image-based tasks. " + "Please create the GT job after the task is created.".format( + models.JobFrameSelectionMethod.MANUAL + ) + ) known_frame_names = {frame.path: frame.frame for frame in images} unknown_requested_frames = [] From 17cc539e2f6253f27c548045ca828265340c9c99 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 12 Sep 2024 17:27:05 +0300 Subject: [PATCH 143/227] Update server api descriptions --- cvat/apps/engine/serializers.py | 18 ++++++++++-------- cvat/schema.yml | 12 +++++++----- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 39c1c2f175b3..05c6672afe1c 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1051,14 +1051,16 @@ class ValidationLayoutParamsSerializer(serializers.ModelSerializer): required=False, allow_null=True, help_text=textwrap.dedent("""\ - The list of frame ids. Applicable only to the "{}" frame selection method + The list of file names to be included in the validation set. + Applicable only to the "{}" frame selection method. + Can only be used for images. """.format(models.JobFrameSelectionMethod.MANUAL)) ) frame_count = serializers.IntegerField( min_value=1, required=False, help_text=textwrap.dedent("""\ - The number of frames included in the GT job. + The number of frames to be included in the validation set. Applicable only to the "{}" frame selection method """.format(models.JobFrameSelectionMethod.RANDOM_UNIFORM)) ) @@ -1067,7 +1069,7 @@ class ValidationLayoutParamsSerializer(serializers.ModelSerializer): allow_null=True, validators=[field_validation.validate_percent], help_text=textwrap.dedent("""\ - The share of frames included in the GT job. + The share of frames to be included in the validation set. Applicable only to the "{}" frame selection method """.format(models.JobFrameSelectionMethod.RANDOM_UNIFORM)) ) @@ -1076,7 +1078,7 @@ class ValidationLayoutParamsSerializer(serializers.ModelSerializer): required=False, allow_null=True, help_text=textwrap.dedent("""\ - The number of frames included in the GT job from each annotation job. + The number of frames to be included in the validation set from each annotation job. Applicable only to the "{}" frame selection method """.format(models.JobFrameSelectionMethod.RANDOM_PER_JOB)) ) @@ -1085,7 +1087,7 @@ class ValidationLayoutParamsSerializer(serializers.ModelSerializer): allow_null=True, validators=[field_validation.validate_percent], help_text=textwrap.dedent("""\ - The share of frames included in the GT job from each annotation job. + The share of frames to be included in the validation set from each annotation job. Applicable only to the "{}" frame selection method """.format(models.JobFrameSelectionMethod.RANDOM_PER_JOB)) ) @@ -1155,9 +1157,9 @@ def validate(self, attrs): ) ) - if attrs.get('frames'): - unique_frames = set(attrs['frames']) - if len(unique_frames) != len(attrs['frames']): + if frames := attrs.get('frames'): + unique_frames = set(frames) + if len(unique_frames) != len(frames): raise serializers.ValidationError("Frames must not repeat") return super().validate(attrs) diff --git a/cvat/schema.yml b/cvat/schema.yml index 3c653e47a390..7fd651f9f2d2 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -10823,33 +10823,35 @@ components: writeOnly: true nullable: true description: | - The list of frame ids. Applicable only to the "manual" frame selection method + The list of file names to be included in the validation set. + Applicable only to the "manual" frame selection method. + Can only be used for images. frame_count: type: integer minimum: 1 description: | - The number of frames included in the GT job. + The number of frames to be included in the validation set. Applicable only to the "random_uniform" frame selection method frame_share: type: number format: double nullable: true description: | - The share of frames included in the GT job. + The share of frames to be included in the validation set. Applicable only to the "random_uniform" frame selection method frames_per_job_count: type: integer minimum: 1 nullable: true description: | - The number of frames included in the GT job from each annotation job. + The number of frames to be included in the validation set from each annotation job. Applicable only to the "random_per_job" frame selection method frames_per_job_share: type: number format: double nullable: true description: | - The share of frames included in the GT job from each annotation job. + The share of frames to be included in the validation set from each annotation job. Applicable only to the "random_per_job" frame selection method required: - frame_selection_method From df90b338e4354ee68de0e92c4a45dc33b235b572 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 12 Sep 2024 19:45:32 +0300 Subject: [PATCH 144/227] Fix cached chunk indicators in frame player --- cvat-core/src/frames.ts | 12 ++++++++++++ cvat-core/src/session-implementation.ts | 9 +++++++++ cvat-core/src/session.ts | 6 ++++++ cvat-ui/src/actions/annotation-actions.ts | 7 ++++--- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index 8d2c0117d48a..37fa51242e30 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -822,6 +822,18 @@ export function getCachedChunks(jobID): number[] { return frameDataCache[jobID].provider.cachedChunks(true); } +export function getJobFrameNumbers(jobID): number[] { + if (!(jobID in frameDataCache)) { + return []; + } + + const { meta, startFrame } = frameDataCache[jobID]; + const dataStartFrame = getDataStartFrame(meta, startFrame); + return meta.getDataFrameNumbers().map((dataFrameNumber: number): number => ( + getFrameNumber(dataFrameNumber, dataStartFrame, meta.frameStep) + )); +} + export function clear(jobID: number): void { if (jobID in frameDataCache) { frameDataCache[jobID].provider.close(); diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 4afcce184e81..961771708726 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -18,6 +18,7 @@ import { deleteFrame, restoreFrame, getCachedChunks, + getJobFrameNumbers, clear as clearFrames, findFrame, getContextImage, @@ -244,6 +245,14 @@ export function implementJob(Job: typeof JobClass): typeof JobClass { }, }); + Object.defineProperty(Job.prototype.frames.frameNumbers, 'implementation', { + value: function includedFramesImplementation( + this: JobClass, + ): ReturnType { + return Promise.resolve(getJobFrameNumbers(this.id)); + }, + }); + Object.defineProperty(Job.prototype.frames.preview, 'implementation', { value: function previewImplementation( this: JobClass, diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 1985a72b2683..d0d4e5e02182 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -233,6 +233,10 @@ function buildDuplicatedAPI(prototype) { const result = await PluginRegistry.apiWrapper.call(this, prototype.frames.cachedChunks); return result; }, + async frameNumbers() { + const result = await PluginRegistry.apiWrapper.call(this, prototype.frames.frameNumbers); + return result; + }, async preview() { const result = await PluginRegistry.apiWrapper.call(this, prototype.frames.preview); return result; @@ -380,6 +384,7 @@ export class Session { restore: (frame: number) => Promise; save: () => Promise; cachedChunks: () => Promise; + frameNumbers: () => Promise; preview: () => Promise; contextImage: (frame: number) => Promise>; search: ( @@ -443,6 +448,7 @@ export class Session { restore: Object.getPrototypeOf(this).frames.restore.bind(this), save: Object.getPrototypeOf(this).frames.save.bind(this), cachedChunks: Object.getPrototypeOf(this).frames.cachedChunks.bind(this), + frameNumbers: Object.getPrototypeOf(this).frames.frameNumbers.bind(this), preview: Object.getPrototypeOf(this).frames.preview.bind(this), search: Object.getPrototypeOf(this).frames.search.bind(this), contextImage: Object.getPrototypeOf(this).frames.contextImage.bind(this), diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 739acac410ab..b3fa8b503aaa 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -587,12 +587,13 @@ export function confirmCanvasReadyAsync(): ThunkAction { const { instance: job } = state.annotation.job; const { changeFrameEvent } = state.annotation.player.frame; const chunks = await job.frames.cachedChunks() as number[]; - const { startFrame, stopFrame, dataChunkSize } = job; + const includedFrames = await job.frames.frameNumbers() as number[]; + const { frameCount, dataChunkSize } = job; const ranges = chunks.map((chunk) => ( [ - Math.max(startFrame, chunk * dataChunkSize), - Math.min(stopFrame, (chunk + 1) * dataChunkSize - 1), + includedFrames[chunk * dataChunkSize], + includedFrames[Math.min(frameCount - 1, (chunk + 1) * dataChunkSize - 1)], ] )).reduce>((acc, val) => { if (acc.length && acc[acc.length - 1][1] + 1 === val[0]) { From 6ccb7dba08d245cccb33e29b599f4027b941e209 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 13 Sep 2024 11:50:19 +0300 Subject: [PATCH 145/227] Fix chunk predecode logic --- cvat-core/src/frames.ts | 57 ++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index 37fa51242e30..ed948f7a263d 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -56,6 +56,7 @@ export class FramesMetaData { public startFrame: number; public stopFrame: number; public frameStep: number; + public chunkCount: number; #updateTrigger: FieldUpdateTrigger; @@ -150,6 +151,17 @@ export class FramesMetaData { }, }), ); + + const chunkCount: number = Math.ceil(this.getDataFrameNumbers().length / this.chunkSize); + + Object.defineProperties( + this, + Object.freeze({ + chunkCount: { + get: () => chunkCount, + }, + }), + ); } getUpdated(): Record { @@ -328,7 +340,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { imageData: ImageBitmap | Blob; } | Blob>((resolve, reject) => { const { - meta, provider, prefetchAnalyzer, chunkSize, startFrame, stopFrame, + meta, provider, prefetchAnalyzer, chunkSize, startFrame, decodeForward, forwardStep, decodedBlocksCacheSize, } = frameDataCache[this.jobID]; @@ -337,7 +349,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { const requestedDataFrameNumber = getDataFrameNumber( this.number, dataStartFrame, meta.frameStep, ); - const chunkNumber = meta.getFrameChunkIndex(requestedDataFrameNumber); + const chunkIndex = meta.getFrameChunkIndex(requestedDataFrameNumber); const segmentFrameNumbers = meta.getDataFrameNumbers().map( (dataFrameNumber: number) => getFrameNumber( dataFrameNumber, dataStartFrame, meta.frameStep, @@ -345,19 +357,24 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { ); const frame = provider.frame(this.number); - function findTheNextNotDecodedChunk(searchFrom: number): number { - let nextFrameIndex = searchFrom + forwardStep; - let nextChunkNumber = Math.floor(nextFrameIndex / chunkSize); - while (nextChunkNumber === chunkNumber) { + function findTheNextNotDecodedChunk(currentFrameIndex: number): number | null { + const { chunkCount } = meta; + let nextFrameIndex = currentFrameIndex + forwardStep; + let nextChunkIndex = Math.floor(nextFrameIndex / chunkSize); + while (nextChunkIndex === chunkIndex) { nextFrameIndex += forwardStep; - nextChunkNumber = Math.floor(nextFrameIndex / chunkSize); + nextChunkIndex = Math.floor(nextFrameIndex / chunkSize); + } + + if (chunkCount <= nextChunkIndex) { + return null; } - if (provider.isChunkCached(nextChunkNumber)) { + if (provider.isChunkCached(nextChunkIndex)) { return findTheNextNotDecodedChunk(nextFrameIndex); } - return nextChunkNumber; + return nextChunkIndex; } if (frame) { @@ -368,12 +385,12 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { (chunk) => provider.isChunkCached(chunk), ) && decodedBlocksCacheSize > 1 && !frameDataCache[this.jobID].activeChunkRequest ) { - const nextChunkNumber = findTheNextNotDecodedChunk( + const nextChunkIndex = findTheNextNotDecodedChunk( meta.getFrameIndex(requestedDataFrameNumber), ); const predecodeChunksMax = Math.floor(decodedBlocksCacheSize / 2); - if (startFrame + nextChunkNumber * chunkSize <= stopFrame && - nextChunkNumber <= chunkNumber + predecodeChunksMax + if (nextChunkIndex !== null && + nextChunkIndex <= chunkIndex + predecodeChunksMax ) { frameDataCache[this.jobID].activeChunkRequest = new Promise((resolveForward) => { const releasePromise = (): void => { @@ -382,7 +399,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { }; frameDataCache[this.jobID].getChunk( - nextChunkNumber, ChunkQuality.COMPRESSED, + nextChunkIndex, ChunkQuality.COMPRESSED, ).then((chunk: ArrayBuffer) => { if (!(this.jobID in frameDataCache)) { // check if frameDataCache still exist @@ -394,10 +411,10 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { provider.cleanup(1); provider.requestDecodeBlock( chunk, - nextChunkNumber, + nextChunkIndex, segmentFrameNumbers.slice( - nextChunkNumber * chunkSize, - (nextChunkNumber + 1) * chunkSize, + nextChunkIndex * chunkSize, + (nextChunkIndex + 1) * chunkSize, ), () => {}, releasePromise, @@ -445,7 +462,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { ) => { let wasResolved = false; frameDataCache[this.jobID].getChunk( - chunkNumber, ChunkQuality.COMPRESSED, + chunkIndex, ChunkQuality.COMPRESSED, ).then((chunk: ArrayBuffer) => { try { if (!(this.jobID in frameDataCache)) { @@ -459,10 +476,10 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { provider .requestDecodeBlock( chunk, - chunkNumber, + chunkIndex, segmentFrameNumbers.slice( - chunkNumber * chunkSize, - (chunkNumber + 1) * chunkSize, + chunkIndex * chunkSize, + (chunkIndex + 1) * chunkSize, ), (_frame: number, bitmap: ImageBitmap | Blob) => { if (decodeForward) { From 1fb68bcb2860616ff56d5bb240d581cb4a2b60b3 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 13 Sep 2024 11:58:50 +0300 Subject: [PATCH 146/227] Rename chunkNumber to chunkIndex where necessary --- cvat-core/src/frames.ts | 2 +- cvat-core/src/session.ts | 4 ++-- cvat-data/src/ts/cvat-data.ts | 40 +++++++++++++++++------------------ 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index ed948f7a263d..a5d3c43be4a8 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -34,7 +34,7 @@ const frameDataCache: Record; - getChunk: (chunkNumber: number, quality: ChunkQuality) => Promise; + getChunk: (chunkIndex: number, quality: ChunkQuality) => Promise; }> = {}; // frame meta data storage by job id diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index d0d4e5e02182..54133ff6b667 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -259,11 +259,11 @@ function buildDuplicatedAPI(prototype) { ); return result; }, - async chunk(chunkNumber, quality) { + async chunk(chunkIndex, quality) { const result = await PluginRegistry.apiWrapper.call( this, prototype.frames.chunk, - chunkNumber, + chunkIndex, quality, ); return result; diff --git a/cvat-data/src/ts/cvat-data.ts b/cvat-data/src/ts/cvat-data.ts index 97e796af9df7..baf00ac443c1 100644 --- a/cvat-data/src/ts/cvat-data.ts +++ b/cvat-data/src/ts/cvat-data.ts @@ -73,7 +73,7 @@ decodeContextImages.mutex = new Mutex(); interface BlockToDecode { chunkFrameNumbers: number[]; - chunkNumber: number; + chunkIndex: number; block: ArrayBuffer; onDecodeAll(): void; onDecode(frame: number, bitmap: ImageBitmap | Blob): void; @@ -99,12 +99,12 @@ export class FrameDecoder { private renderHeight: number; private zipWorker: Worker | null; private videoWorker: Worker | null; - private getChunkNumber: (frame: number) => number; + private getChunkIndex: (frame: number) => number; constructor( blockType: BlockType, cachedBlockCount: number, - getChunkNumber: (frame: number) => number, + getChunkIndex: (frame: number) => number, dimension: DimensionType = DimensionType.DIMENSION_2D, ) { this.mutex = new Mutex(); @@ -117,7 +117,7 @@ export class FrameDecoder { this.renderWidth = 1920; this.renderHeight = 1080; - this.getChunkNumber = getChunkNumber; + this.getChunkIndex = getChunkIndex; this.blockType = blockType; this.decodedChunks = {}; @@ -125,8 +125,8 @@ export class FrameDecoder { this.chunkIsBeingDecoded = null; } - isChunkCached(chunkNumber: number): boolean { - return chunkNumber in this.decodedChunks; + isChunkCached(chunkIndex: number): boolean { + return chunkIndex in this.decodedChunks; } hasFreeSpace(): boolean { @@ -175,7 +175,7 @@ export class FrameDecoder { requestDecodeBlock( block: ArrayBuffer, - chunkNumber: number, + chunkIndex: number, chunkFrameNumbers: number[], onDecode: (frame: number, bitmap: ImageBitmap | Blob) => void, onDecodeAll: () => void, @@ -185,7 +185,7 @@ export class FrameDecoder { if (this.requestedChunkToDecode !== null) { // a chunk was already requested to be decoded, but decoding didn't start yet - if (chunkNumber === this.requestedChunkToDecode.chunkNumber) { + if (chunkIndex === this.requestedChunkToDecode.chunkIndex) { // it was the same chunk this.requestedChunkToDecode.onReject(new RequestOutdatedError()); @@ -196,12 +196,12 @@ export class FrameDecoder { this.requestedChunkToDecode.onReject(new RequestOutdatedError()); } } else if (this.chunkIsBeingDecoded === null || - chunkNumber !== this.chunkIsBeingDecoded.chunkNumber + chunkIndex !== this.chunkIsBeingDecoded.chunkIndex ) { // everything was decoded or decoding other chunk is in process this.requestedChunkToDecode = { chunkFrameNumbers, - chunkNumber, + chunkIndex, block, onDecode, onDecodeAll, @@ -225,9 +225,9 @@ export class FrameDecoder { } frame(frameNumber: number): ImageBitmap | Blob | null { - const chunkNumber = this.getChunkNumber(frameNumber); - if (chunkNumber in this.decodedChunks) { - return this.decodedChunks[chunkNumber][frameNumber]; + const chunkIndex = this.getChunkIndex(frameNumber); + if (chunkIndex in this.decodedChunks) { + return this.decodedChunks[chunkIndex][frameNumber]; } return null; @@ -275,8 +275,8 @@ export class FrameDecoder { releaseMutex(); }; try { - const { chunkFrameNumbers, chunkNumber, block } = this.requestedChunkToDecode; - if (chunkNumber !== blockToDecode.chunkNumber) { + const { chunkFrameNumbers, chunkIndex, block } = this.requestedChunkToDecode; + if (chunkIndex !== blockToDecode.chunkIndex) { // request is not relevant, another block was already requested // it happens when A is being decoded, B comes and wait for mutex, C comes and wait for mutex // B is not necessary anymore, because C already was requested @@ -288,7 +288,7 @@ export class FrameDecoder { chunkFrameNumbers[chunkFrameIndex] ); - this.orderedStack = [chunkNumber, ...this.orderedStack]; + this.orderedStack = [chunkIndex, ...this.orderedStack]; this.cleanup(); const decodedFrames: Record = {}; this.chunkIsBeingDecoded = this.requestedChunkToDecode; @@ -325,7 +325,7 @@ export class FrameDecoder { this.chunkIsBeingDecoded.onDecode(frameNumber, decodedFrames[frameNumber]); if (keptIndex === chunkFrameNumbers.length - 1) { - this.decodedChunks[chunkNumber] = decodedFrames; + this.decodedChunks[chunkIndex] = decodedFrames; this.chunkIsBeingDecoded.onDecodeAll(); this.chunkIsBeingDecoded = null; release(); @@ -384,7 +384,7 @@ export class FrameDecoder { this.chunkIsBeingDecoded.onDecode(frameNumber, decodedFrames[frameNumber]); if (decodedCount === chunkFrameNumbers.length - 1) { - this.decodedChunks[chunkNumber] = decodedFrames; + this.decodedChunks[chunkIndex] = decodedFrames; this.chunkIsBeingDecoded.onDecodeAll(); this.chunkIsBeingDecoded = null; release(); @@ -430,10 +430,10 @@ export class FrameDecoder { public cachedChunks(includeInProgress = false): number[] { const chunkIsBeingDecoded = ( includeInProgress && this.chunkIsBeingDecoded ? - this.chunkIsBeingDecoded.chunkNumber : + this.chunkIsBeingDecoded.chunkIndex : null ); - return Object.keys(this.decodedChunks).map((chunkNumber: string) => +chunkNumber).concat( + return Object.keys(this.decodedChunks).map((chunkIndex: string) => +chunkIndex).concat( ...(chunkIsBeingDecoded !== null ? [chunkIsBeingDecoded] : []), ).sort((a, b) => a - b); } From 92d0c7a519e05632b0e82698e4717c508940366f Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 13 Sep 2024 12:39:48 +0300 Subject: [PATCH 147/227] Fix potential prefetch problem with reverse playback --- cvat-core/src/frames.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index a5d3c43be4a8..dda847cf7a72 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -366,7 +366,7 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { nextChunkIndex = Math.floor(nextFrameIndex / chunkSize); } - if (chunkCount <= nextChunkIndex) { + if (nextChunkIndex < 0 || chunkCount <= nextChunkIndex) { return null; } From 62bfb456c7912b3258dfe3cfc02f4c29dd1bd0ed Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 13 Sep 2024 15:55:03 +0300 Subject: [PATCH 148/227] Change the way validation layout is stored in the DB --- cvat/apps/dataset_manager/task.py | 2 +- cvat/apps/engine/backup.py | 33 ++++--- ...laceholder_image_real_frame_id_and_more.py | 34 ++++++- cvat/apps/engine/model_utils.py | 5 + cvat/apps/engine/models.py | 42 +++++++-- cvat/apps/engine/serializers.py | 85 +++++++---------- cvat/apps/engine/task.py | 94 +++++++------------ cvat/schema.yml | 22 ++--- 8 files changed, 168 insertions(+), 149 deletions(-) create mode 100644 cvat/apps/engine/model_utils.py diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 7c84cad3df2a..d0357a50d4c8 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -868,7 +868,7 @@ def _preprocess_input_annotations_for_gt_pool_task( db_job for db_job in self.db_jobs if db_job.type == models.JobType.GROUND_TRUTH ) - # Copy GT pool annotations into normal jobs + # Copy GT pool annotations into other jobs gt_pool_frames = gt_job.segment.frame_set task_validation_frame_groups: dict[int, int] = {} # real_id -> [placeholder_id, ...] task_validation_frame_ids: set[int] = set() diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index 2afa19821bf7..c58e74d41db0 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -35,7 +35,7 @@ LabelSerializer, AnnotationGuideWriteSerializer, AssetWriteSerializer, LabeledDataSerializer, SegmentSerializer, SimpleJobSerializer, TaskReadSerializer, ProjectReadSerializer, ProjectFileSerializer, TaskFileSerializer, RqIdSerializer, - ValidationLayoutParamsSerializer) + ValidationParamsSerializer) from cvat.apps.engine.utils import ( av_scan_paths, process_failed_job, get_rq_job_meta, import_resource_with_clean_up_after, @@ -482,17 +482,26 @@ def serialize_data(): (validation_layout := getattr(self._db_data, 'validation_layout', None)) and validation_layout.mode == models.ValidationMode.GT_POOL ): - validation_layout_serializer = ValidationLayoutParamsSerializer( - instance=validation_layout - ) - validation_layout_params = validation_layout_serializer.data - validation_layout_params['frames'] = list( - validation_layout.frames - .order_by('path') - .values_list('path', flat=True) - .iterator(chunk_size=10000) + validation_params_serializer = ValidationParamsSerializer({ + "mode": validation_layout.mode, + "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, + "frames_per_job_count": validation_layout.frames_per_job_count, + }) + validation_params = validation_params_serializer.data + media_filenames = dict( + self._db_data.images + .order_by('frame') + .filter( + frame__gte=min(validation_layout.frames), + frame__lte=max(validation_layout.frames), + ) + .values_list('frame', 'path') + .all() ) - data['validation_layout'] = validation_layout_params + validation_params['frames'] = [ + media_filenames[frame] for frame in validation_layout.frames + ] + data['validation_layout'] = validation_params return self._prepare_data_meta(data) @@ -760,7 +769,7 @@ def _import_task(self): validation_params = data.pop('validation_layout', None) if validation_params: validation_params['frame_selection_method'] = models.JobFrameSelectionMethod.MANUAL - validation_params_serializer = ValidationLayoutParamsSerializer(data=validation_params) + validation_params_serializer = ValidationParamsSerializer(data=validation_params) validation_params_serializer.is_valid(raise_exception=True) validation_params = validation_params_serializer.data diff --git a/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py b/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py index d6692b476377..36e7b4cff36e 100644 --- a/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py +++ b/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py @@ -1,5 +1,6 @@ -# Generated by Django 4.2.15 on 2024-08-28 13:50 +# Generated by Django 4.2.15 on 2024-09-13 11:34 +import cvat.apps.engine.models from django.db import migrations, models import django.db.models.deletion @@ -22,7 +23,7 @@ class Migration(migrations.Migration): field=models.PositiveIntegerField(default=0), ), migrations.CreateModel( - name="ValidationLayout", + name="ValidationParams", fields=[ ( "id", @@ -50,6 +51,31 @@ class Migration(migrations.Migration): ("frame_share", models.FloatField(null=True)), ("frames_per_job_count", models.IntegerField(null=True)), ("frames_per_job_share", models.FloatField(null=True)), + ( + "task_data", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="validation_params", + to="engine.data", + ), + ), + ], + ), + migrations.CreateModel( + name="ValidationLayout", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "mode", + models.CharField(choices=[("gt", "GT"), ("gt_pool", "GT_POOL")], max_length=32), + ), + ("frames", cvat.apps.engine.models.IntArrayField(default="")), + ("frames_per_job_count", models.IntegerField(null=True)), ( "task_data", models.OneToOneField( @@ -71,11 +97,11 @@ class Migration(migrations.Migration): ), ("path", models.CharField(default="", max_length=1024)), ( - "validation_layout", + "validation_params", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="frames", - to="engine.validationlayout", + to="engine.validationparams", ), ), ], diff --git a/cvat/apps/engine/model_utils.py b/cvat/apps/engine/model_utils.py new file mode 100644 index 000000000000..67d20d0b1944 --- /dev/null +++ b/cvat/apps/engine/model_utils.py @@ -0,0 +1,5 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 1154ec1cdd36..bbf3bf56ad0d 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -231,12 +231,9 @@ def choices(cls): def __str__(self): return self.value -class ValidationLayout(models.Model): - # TODO: find a way to avoid using the same mode for storing request parameters - # before data uploading and after - +class ValidationParams(models.Model): task_data = models.OneToOneField( - 'Data', on_delete=models.CASCADE, related_name="validation_layout" + 'Data', on_delete=models.CASCADE, related_name="validation_params" ) mode = models.CharField(max_length=32, choices=ValidationMode.choices()) @@ -253,11 +250,20 @@ class ValidationLayout(models.Model): frames_per_job_share = models.FloatField(null=True) class ValidationFrame(models.Model): - validation_layout = models.ForeignKey( - ValidationLayout, on_delete=models.CASCADE, related_name="frames" + validation_params = models.ForeignKey( + ValidationParams, on_delete=models.CASCADE, related_name="frames" ) path = models.CharField(max_length=1024, default='') +class ValidationLayout(models.Model): + task_data = models.OneToOneField( + 'Data', on_delete=models.CASCADE, related_name="validation_layout" + ) + + mode = models.CharField(max_length=32, choices=ValidationMode.choices()) + frames = IntArrayField(store_sorted=True, unique_values=True) + frames_per_job_count = models.IntegerField(null=True) + class Data(models.Model): MANIFEST_FILENAME: ClassVar[str] = 'manifest.jsonl' @@ -276,7 +282,14 @@ class Data(models.Model): cloud_storage = models.ForeignKey('CloudStorage', on_delete=models.SET_NULL, null=True, related_name='data') sorting_method = models.CharField(max_length=15, choices=SortingMethod.choices(), default=SortingMethod.LEXICOGRAPHICAL) deleted_frames = IntArrayField(store_sorted=True, unique_values=True) - validation_layout: ValidationLayout # TODO: maybe allow None to avoid hasattr everywhere + + validation_params: ValidationParams + """ + Represents user-requested validation params before task is created. + After the task creation, 'validation_layout' is used instead. + """ + + validation_layout: ValidationLayout class Meta: default_permissions = () @@ -343,6 +356,19 @@ def make_dirs(self): os.makedirs(self.get_original_cache_dirname()) os.makedirs(self.get_upload_dirname()) + @transaction.atomic + def update_validation_layout( + self, validation_layout: Optional[ValidationLayout] + ) -> Optional[ValidationLayout]: + if validation_layout: + validation_layout.task_data = self + validation_layout.save() + + ValidationParams.objects.filter(task_data_id=self.id).delete() + ValidationFrame.objects.filter(validation_params__task_data_id=self.id).delete() + + return validation_layout + class Video(models.Model): data = models.OneToOneField(Data, on_delete=models.CASCADE, related_name="video", null=True) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 05c6672afe1c..800d04f1a7de 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -26,7 +26,7 @@ from django.utils import timezone from cvat.apps.dataset_manager.formats.utils import get_label_color -from cvat.apps.engine.utils import parse_exception_message +from cvat.apps.engine.utils import format_list, parse_exception_message 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 @@ -731,6 +731,10 @@ def validate(self, attrs): elif frame_selection_method == models.JobFrameSelectionMethod.MANUAL: field_validation.require_field(attrs, "frames") + frames = attrs['frames'] + if not frames: + raise serializers.ValidationError("The list of frames cannot be empty") + if ( frame_selection_method != models.JobFrameSelectionMethod.MANUAL and attrs.get('frames') @@ -769,20 +773,20 @@ def create(self, validated_data): 'cannot have more than 1 GT job' ) - size = task.data.size + task_size = task.data.size valid_frame_ids = task.data.get_valid_frame_indices() # TODO: refactor, test frame_selection_method = validated_data.pop("frame_selection_method") if frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: if frame_count := validated_data.pop("frame_count", None): - if size < frame_count: + if task_size < frame_count: raise serializers.ValidationError( f"The number of frames requested ({frame_count}) " - f"must be not be greater than the number of the task frames ({size})" + f"must be not be greater than the number of the task frames ({task_size})" ) elif frame_share := validated_data.pop("frame_share", None): - frame_count = max(1, int(frame_share * size)) + frame_count = max(1, int(frame_share * task_size)) else: raise serializers.ValidationError( "The number of validation frames is not specified" @@ -795,7 +799,7 @@ def create(self, validated_data): from numpy import random rng = random.Generator(random.MT19937(seed=seed)) - if seed is not None and frame_count < size: + if seed is not None and frame_count < task_size: # Reproduce the old (a little bit incorrect) behavior that existed before # https://github.com/cvat-ai/cvat/pull/7126 # to make the old seed-based sequences reproducible @@ -806,13 +810,13 @@ def create(self, validated_data): ).tolist() elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_PER_JOB: if frame_count := validated_data.pop("frames_per_job_count", None): - if size < frame_count: + if task_size < frame_count: raise serializers.ValidationError( f"The number of frames requested ({frame_count}) " f"must be not be greater than the segment size ({task.segment_size})" ) elif frame_share := validated_data.pop("frames_per_job_share", None): - frame_count = max(1, int(frame_share * size)) + frame_count = max(1, int(frame_share * task_size)) else: raise serializers.ValidationError( "The number of validation frames is not specified" @@ -833,48 +837,25 @@ def create(self, validated_data): elif frame_selection_method == models.JobFrameSelectionMethod.MANUAL: frames = validated_data.pop("frames") - if not frames: - raise serializers.ValidationError("The list of frames cannot be empty") - unique_frames = set(frames) if len(unique_frames) != len(frames): raise serializers.ValidationError(f"Frames must not repeat") - invalid_ids = unique_frames.difference(valid_frame_ids) + invalid_ids = unique_frames.difference(range(task_size)) if invalid_ids: raise serializers.ValidationError( - "The following frames are not included " - f"in the task: {','.join(map(str, invalid_ids))}" + "The following frames do not exist in the task: {}".format( + format_list(invalid_ids) + ) ) else: raise serializers.ValidationError( f"Unexpected frame selection method '{frame_selection_method}'" ) - # Update validation layout in the task - frame_paths = list( - models.Image.objects - .order_by('frame') - .values_list('path', flat=True) - .iterator(chunk_size=10000) - ) - validation_layout_params = { - "mode": models.ValidationMode.GT, - "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, - "frames": [frame_paths[frame_id] for frame_id in frames], - - # reset other fields - "random_seed": None, - "frame_count": None, - "frame_share": None, - "frames_per_job_count": None, - "frames_per_job_share": None, - } - validation_layout_serializer = ValidationLayoutParamsSerializer( - instance=getattr(task.data, 'validation_layout', None), data=validation_layout_params + task.data.update_validation_layout( + models.ValidationLayout(mode=models.ValidationMode.GT, frames=frames) ) - assert validation_layout_serializer.is_valid(raise_exception=False) - validation_layout_serializer.save(task_data=task.data) # Save the new job segment = models.Segment.objects.create( @@ -1039,7 +1020,7 @@ def __init__(self, *args, **kwargs): kwargs.setdefault('help_text', textwrap.dedent(__class__.__doc__)) super().__init__(*args, **kwargs) -class ValidationLayoutParamsSerializer(serializers.ModelSerializer): +class ValidationParamsSerializer(serializers.ModelSerializer): mode = serializers.ChoiceField(choices=models.ValidationMode.choices(), required=True) frame_selection_method = serializers.ChoiceField( choices=models.JobFrameSelectionMethod.choices(), required=True @@ -1108,7 +1089,7 @@ class Meta: 'mode', 'frame_selection_method', 'random_seed', 'frames', 'frame_count', 'frame_share', 'frames_per_job_count', 'frames_per_job_share', ) - model = models.ValidationLayout + model = models.ValidationParams def validate(self, attrs): attrs = field_validation.drop_null_keys(attrs) @@ -1165,14 +1146,14 @@ def validate(self, attrs): return super().validate(attrs) @transaction.atomic - def create(self, validated_data: dict[str, Any]) -> models.ValidationLayout: + def create(self, validated_data: dict[str, Any]) -> models.ValidationParams: frames = validated_data.pop('frames', None) instance = super().create(validated_data) if frames: models.ValidationFrame.objects.bulk_create( - models.ValidationFrame(validation_layout=instance, path=frame) + models.ValidationFrame(validation_params=instance, path=frame) for frame in frames ) @@ -1180,17 +1161,17 @@ def create(self, validated_data: dict[str, Any]) -> models.ValidationLayout: @transaction.atomic def update( - self, instance: models.ValidationLayout, validated_data: dict[str, Any] - ) -> models.ValidationLayout: + self, instance: models.ValidationParams, validated_data: dict[str, Any] + ) -> models.ValidationParams: frames = validated_data.pop('frames', None) instance = super().update(instance, validated_data) if frames: - models.ValidationFrame.objects.filter(validation_layout=instance).delete() + models.ValidationFrame.objects.filter(validation_params=instance).delete() models.ValidationFrame.objects.bulk_create( - models.ValidationFrame(validation_layout=instance, path=frame) + models.ValidationFrame(validation_params=instance, path=frame) for frame in frames ) @@ -1282,7 +1263,7 @@ class DataSerializer(serializers.ModelSerializer): pass the list of file names in the required order. """.format(models.SortingMethod.PREDEFINED)) ) - validation_params = ValidationLayoutParamsSerializer(allow_null=True, required=False) + validation_params = ValidationParamsSerializer(allow_null=True, required=False) class Meta: model = models.Data @@ -1351,7 +1332,7 @@ def validate(self, attrs): validation_params = attrs.pop('validation_params', None) if validation_params: - validation_params_serializer = ValidationLayoutParamsSerializer(data=validation_params) + validation_params_serializer = ValidationParamsSerializer(data=validation_params) validation_params_serializer.is_valid(raise_exception=True) attrs['validation_params'] = validation_params_serializer.validated_data @@ -1370,9 +1351,9 @@ def create(self, validated_data): db_data.save() if validation_params: - validation_params_serializer = ValidationLayoutParamsSerializer(data=validation_params) + validation_params_serializer = ValidationParamsSerializer(data=validation_params) validation_params_serializer.is_valid(raise_exception=True) - db_data.validation_layout = validation_params_serializer.save(task_data=db_data) + db_data.validation_params = validation_params_serializer.save(task_data=db_data) return db_data @@ -1388,11 +1369,11 @@ def update(self, instance, validated_data): instance.save() if validation_params: - validation_params_serializer = ValidationLayoutParamsSerializer( - instance=getattr(instance, "validation_layout", None), data=validation_params + validation_params_serializer = ValidationParamsSerializer( + instance=getattr(instance, "validation_params", None), data=validation_params ) validation_params_serializer.is_valid(raise_exception=True) - instance.validation_layout = validation_params_serializer.save(task_data=instance) + instance.validation_params = validation_params_serializer.save(task_data=instance) return instance diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 3e6adf9724a2..8095d1d6df1a 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -37,7 +37,7 @@ ValidateDimension, ZipChunkWriter, ZipCompressedChunkWriter, get_mime, sort ) from cvat.apps.engine.models import RequestAction, RequestTarget -from cvat.apps.engine.serializers import ValidationLayoutParamsSerializer +from cvat.apps.engine.serializers import ValidationParamsSerializer from cvat.apps.engine.utils import ( av_scan_paths, format_list,get_rq_job_meta, define_dependent_job, get_rq_lock_by_user, preload_images ) @@ -1158,23 +1158,24 @@ def _update_status(msg: str) -> None: assert job_file_mapping[-1] == validation_params['frames'] job_file_mapping.pop(-1) - validation_frames = list(frame_idx_map.values()) - - # Save the created validation layout - validation_params = { - "mode": models.ValidationMode.GT_POOL, - "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, - "frames": [images[frame_id].path for frame_id in validation_frames], - "frames_per_job_count": validation_params['frames_per_job_count'], + # Update manifest + manifest = ImageManifestManager(db_data.get_manifest_path()) + manifest.link( + sources=[extractor.get_path(image.frame) for image in images], + meta={ + k: {'related_images': related_images[k] } + for k in related_images + }, + data_dir=upload_dir, + DIM_3D=(db_task.dimension == models.DimensionType.DIM_3D), + ) + manifest.create() - # reset other fields - "random_seed": None, - "frame_count": None, - "frame_share": None, - "frames_per_job_share": None, - } - validation_layout_serializer = ValidationLayoutParamsSerializer() - validation_layout_serializer.update(db_data.validation_layout, validation_params) + db_data.update_validation_layout(models.ValidationLayout( + mode=models.ValidationMode.GT_POOL, + frames=list(frame_idx_map.values()), + frames_per_job_count=validation_params["frames_per_job_count"], + )) elif validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL: if db_task.mode != 'annotation': raise ValidationError( @@ -1314,25 +1315,11 @@ def _update_status(msg: str) -> None: ) manifest.create() - validation_frames = pool_frames - - # Save the created validation layout - # TODO: try to find a way to avoid using the same model for storing the user request - # and internal data - validation_params = { - "mode": models.ValidationMode.GT_POOL, - "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, - "frames": [images[frame_id].path for frame_id in validation_frames], - "frames_per_job_count": frames_per_job_count, - - # reset other fields - "random_seed": None, - "frame_count": None, - "frame_share": None, - "frames_per_job_share": None, - } - validation_layout_serializer = ValidationLayoutParamsSerializer() - validation_layout_serializer.update(db_data.validation_layout, validation_params) + db_data.update_validation_layout(models.ValidationLayout( + mode=models.ValidationMode.GT_POOL, + frames=pool_frames, + frames_per_job_count=frames_per_job_count, + )) if db_task.mode == 'annotation': models.Image.objects.bulk_create(images) @@ -1437,41 +1424,26 @@ def _update_status(msg: str) -> None: f'Unknown frame selection method {validation_params["frame_selection_method"]}' ) - validation_frames = sorted(validation_frames) - - # Save the created validation layout - # TODO: try to find a way to avoid using the same model for storing the user request - # and internal data - validation_params = { - "mode": models.ValidationMode.GT, - "frame_selection_method": models.JobFrameSelectionMethod.MANUAL, - "frames": [images[frame_id].path for frame_id in validation_frames], - - # reset other fields - "random_seed": None, - "frame_count": None, - "frame_share": None, - "frames_per_job_count": None, - "frames_per_job_share": None, - } - validation_layout_serializer = ValidationLayoutParamsSerializer() - validation_layout_serializer.update(db_data.validation_layout, validation_params) + db_data.update_validation_layout(models.ValidationLayout( + mode=models.ValidationMode.GT, + frames=sorted(validation_frames), + )) # TODO: refactor - if validation_params: - if validation_params['mode'] == models.ValidationMode.GT: + if hasattr(db_data, 'validation_layout'): + if db_data.validation_layout.mode == models.ValidationMode.GT: db_gt_segment = models.Segment( task=db_task, start_frame=0, stop_frame=db_data.size - 1, - frames=validation_frames, + frames=db_data.validation_layout.frames, type=models.SegmentType.SPECIFIC_FRAMES, ) - elif validation_params['mode'] == models.ValidationMode.GT_POOL: + elif db_data.validation_layout.mode == models.ValidationMode.GT_POOL: db_gt_segment = models.Segment( task=db_task, - start_frame=min(validation_frames), - stop_frame=max(validation_frames), + start_frame=min(db_data.validation_layout.frames), + stop_frame=max(db_data.validation_layout.frames), type=models.SegmentType.RANGE, ) else: diff --git a/cvat/schema.yml b/cvat/schema.yml index 7fd651f9f2d2..3a9640592e69 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: CVAT REST API - version: 2.18.0 + version: 2.18.0.dev20240912142705 description: REST API for Computer Vision Annotation Tool (CVAT) termsOfService: https://www.google.com/policies/terms/ contact: @@ -7462,7 +7462,7 @@ components: pass the list of file names in the required order. validation_params: allOf: - - $ref: '#/components/schemas/ValidationLayoutParamsRequest' + - $ref: '#/components/schemas/ValidationParamsRequest' nullable: true required: - image_quality @@ -10798,7 +10798,15 @@ components: maxLength: 150 required: - username - ValidationLayoutParamsRequest: + ValidationMode: + enum: + - gt + - gt_pool + type: string + description: |- + * `gt` - GT + * `gt_pool` - GT_POOL + ValidationParamsRequest: type: object properties: mode: @@ -10856,14 +10864,6 @@ components: required: - frame_selection_method - mode - ValidationMode: - enum: - - gt - - gt_pool - type: string - description: |- - * `gt` - GT - * `gt_pool` - GT_POOL WebhookContentType: enum: - application/json From 03f58ed33084ae3cb27e49cb8c21fb2d81d4b258 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 13 Sep 2024 18:57:48 +0300 Subject: [PATCH 149/227] Add tests for task creation with gt job from video, fix frames_per_job method --- cvat/apps/engine/serializers.py | 19 +++- cvat/apps/engine/task.py | 40 +++++--- tests/python/rest_api/test_tasks.py | 148 +++++++++++++++++++++++++--- 3 files changed, 176 insertions(+), 31 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 800d04f1a7de..b7d50ea2deed 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -776,7 +776,7 @@ def create(self, validated_data): task_size = task.data.size valid_frame_ids = task.data.get_valid_frame_indices() - # TODO: refactor, test + # TODO: refactor frame_selection_method = validated_data.pop("frame_selection_method") if frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: if frame_count := validated_data.pop("frame_count", None): @@ -829,10 +829,23 @@ def create(self, validated_data): from numpy import random rng = random.Generator(random.MT19937(seed=seed)) - frames = [] + frames: list[int] = [] + overlap = task.overlap for segment in task.segment_set.all(): + segment_frames = set(segment.frame_set) + selected_frames = segment_frames.intersection(frames) + selected_count = len(selected_frames) + + missing_count = min(len(segment_frames), frame_count) - selected_count + if missing_count <= 0: + continue + + selectable_segment_frames = set( + sorted(segment.frame_set)[overlap * (segment.start_frame != 0) : ] + ).difference(selected_frames) + frames.extend(rng.choice( - list(segment.frame_set), size=frame_count, shuffle=False, replace=False + tuple(selectable_segment_frames), size=missing_count, replace=False ).tolist()) elif frame_selection_method == models.JobFrameSelectionMethod.MANUAL: frames = validated_data.pop("frames") diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 8095d1d6df1a..af76fd674df3 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1141,9 +1141,9 @@ def _update_status(msg: str) -> None: ): # Validation frames must be in the end of the images list. Collect their ids frame_idx_map: dict[str, int] = {} - for i, frame_name in enumerate(validation_params['frames']): + for i, frame_filename in enumerate(validation_params['frames']): image = images[-len(validation_params['frames']) + i] - assert frame_name == image.path + assert frame_filename == image.path frame_idx_map[image.path] = image.frame # Store information about the real frame placement in validation frames in jobs @@ -1212,10 +1212,10 @@ def _update_status(msg: str) -> None: case models.JobFrameSelectionMethod.MANUAL: known_frame_names = {frame.path: frame.frame for frame in images} unknown_requested_frames = [] - for frame in db_data.validation_layout.frames.all(): - frame_id = known_frame_names.get(frame.path) + for frame_filename in db_data.validation_layout.frames.all(): + frame_id = known_frame_names.get(frame_filename.path) if frame_id is None: - unknown_requested_frames.append(frame.path) + unknown_requested_frames.append(frame_filename.path) continue pool_frames.append(frame_id) @@ -1360,16 +1360,15 @@ def _update_status(msg: str) -> None: seed = validation_params.get("random_seed") rng = random.Generator(random.MT19937(seed=seed)) - validation_frames: list[int] = [] match validation_params["frame_selection_method"]: case models.JobFrameSelectionMethod.RANDOM_UNIFORM: - all_frames = range(len(images)) + all_frames = range(db_data.size) if frame_count := validation_params.get("frame_count"): - if len(images) < frame_count: + if db_data.size < frame_count: raise ValidationError( f"The number of validation frames requested ({frame_count}) " - f"is greater that the number of task frames ({len(images)})" + f"is greater that the number of task frames ({db_data.size})" ) elif frame_share := validation_params.get("frame_share"): frame_count = max(1, int(frame_share * len(all_frames))) @@ -1391,9 +1390,23 @@ def _update_status(msg: str) -> None: else: raise ValidationError("The number of validation frames is not specified") + validation_frames: list[int] = [] + overlap = db_task.overlap for segment in db_task.segment_set.all(): + segment_frames = set(segment.frame_set) + selected_frames = segment_frames.intersection(validation_frames) + selected_count = len(selected_frames) + + missing_count = min(len(segment_frames), frame_count) - selected_count + if missing_count <= 0: + continue + + selectable_segment_frames = set( + sorted(segment.frame_set)[overlap * (segment.start_frame != 0) : ] + ).difference(selected_frames) + validation_frames.extend(rng.choice( - list(segment.frame_set), size=frame_count, shuffle=False, replace=False + tuple(selectable_segment_frames), size=missing_count, replace=False ).tolist()) case models.JobFrameSelectionMethod.MANUAL: if not images: @@ -1405,12 +1418,13 @@ def _update_status(msg: str) -> None: ) ) + validation_frames: list[int] = [] known_frame_names = {frame.path: frame.frame for frame in images} unknown_requested_frames = [] - for frame in db_data.validation_layout.frames.all(): - frame_id = known_frame_names.get(frame.path) + for frame_filename in validation_params['frames']: + frame_id = known_frame_names.get(frame_filename) if frame_id is None: - unknown_requested_frames.append(frame.path) + unknown_requested_frames.append(frame_filename) continue validation_frames.append(frame_id) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 49c61a4e3ea1..0e43a8f06ec5 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -2174,8 +2174,9 @@ def test_can_create_task_with_honeypots( ), ("manual", {}), ], + idgen=lambda **args: "-".join([args["frame_selection_method"], *args["method_params"]]), ) - def test_can_create_task_with_gt_job( + def test_can_create_task_with_gt_job_from_images( self, request: pytest.FixtureRequest, frame_selection_method: str, @@ -2183,7 +2184,6 @@ def test_can_create_task_with_gt_job( ): segment_size = 4 total_frame_count = 15 - validation_frames_count = 5 resulting_task_size = total_frame_count image_files = generate_image_files(total_frame_count) @@ -2194,6 +2194,8 @@ def test_can_create_task_with_gt_job( validation_params["random_seed"] = 42 if frame_selection_method == "random_uniform": + validation_frames_count = 5 + for method_param in method_params: if method_param == "frame_count": validation_params[method_param] = validation_frames_count @@ -2203,6 +2205,9 @@ def test_can_create_task_with_gt_job( assert False elif frame_selection_method == "random_per_job": validation_per_job_count = 2 + validation_frames_count = validation_per_job_count * math.ceil( + total_frame_count / segment_size + ) for method_param in method_params: if method_param == "frames_per_job_count": @@ -2212,6 +2217,8 @@ def test_can_create_task_with_gt_job( else: assert False elif frame_selection_method == "manual": + validation_frames_count = 5 + rng = np.random.Generator(np.random.MT19937(seed=42)) validation_params["frames"] = rng.choice( [f.name for f in image_files], validation_frames_count, replace=False @@ -2255,19 +2262,21 @@ def test_can_create_task_with_gt_job( assert task.size == resulting_task_size assert task_meta.size == resulting_task_size - validation_frames = set(f.name for f in gt_job_metas[0].frames) - if frame_selection_method == "manual": - assert sorted( - gt_job_metas[0].frames[rel_frame_id].name - for rel_frame_id, abs_frame_id in enumerate( - range( - gt_job_metas[0].start_frame, - gt_job_metas[0].stop_frame + 1, - int((gt_job_metas[0].frame_filter or "step=1").split("=")[1]), - ) + validation_frames = [ + gt_job_metas[0].frames[rel_frame_id].name + for rel_frame_id, abs_frame_id in enumerate( + range( + gt_job_metas[0].start_frame, + gt_job_metas[0].stop_frame + 1, + int((gt_job_metas[0].frame_filter or "step=1").split("=")[1]), ) - if abs_frame_id in gt_job_metas[0].included_frames - ) == sorted(validation_params["frames"]) + ) + if abs_frame_id in gt_job_metas[0].included_frames + ] + if frame_selection_method == "manual": + assert sorted(validation_params["frames"]) == sorted(validation_frames) + + assert len(validation_frames) == validation_frames_count # frames must not repeat assert sorted(f.name for f in image_files) == sorted(f.name for f in task_meta.frames) @@ -2276,9 +2285,118 @@ def test_can_create_task_with_gt_job( # each job must have the specified number of validation frames for job_meta in annotation_job_metas: assert ( - len(set(f.name for f in job_meta.frames if f.name in validation_frames)) + len([f.name for f in job_meta.frames if f.name in validation_frames]) + == validation_per_job_count + ) + + @parametrize( + "frame_selection_method, method_params", + [ + *tuple(product(["random_uniform"], [{"frame_count"}, {"frame_share"}])), + *tuple( + product(["random_per_job"], [{"frames_per_job_count"}, {"frames_per_job_share"}]) + ), + ], + idgen=lambda **args: "-".join([args["frame_selection_method"], *args["method_params"]]), + ) + def test_can_create_task_with_gt_job_from_video( + self, + request: pytest.FixtureRequest, + frame_selection_method: str, + method_params: set[str], + ): + segment_size = 4 + total_frame_count = 15 + resulting_task_size = total_frame_count + + video_file = generate_video_file(total_frame_count) + + validation_params = {"mode": "gt", "frame_selection_method": frame_selection_method} + + if "random" in frame_selection_method: + validation_params["random_seed"] = 42 + + if frame_selection_method == "random_uniform": + validation_frames_count = 5 + + for method_param in method_params: + if method_param == "frame_count": + validation_params[method_param] = validation_frames_count + elif method_param == "frame_share": + validation_params[method_param] = validation_frames_count / total_frame_count + else: + assert False + elif frame_selection_method == "random_per_job": + validation_per_job_count = 2 + + for method_param in method_params: + if method_param == "frames_per_job_count": + validation_params[method_param] = validation_per_job_count + elif method_param == "frames_per_job_share": + validation_params[method_param] = validation_per_job_count / segment_size + else: + assert False + + task_params = { + "name": request.node.name, + "labels": [{"name": "a"}], + "segment_size": segment_size, + } + + data_params = { + "image_quality": 70, + "client_files": [video_file], + "validation_params": validation_params, + } + + task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params) + + with make_api_client(self._USERNAME) as api_client: + (task, _) = api_client.tasks_api.retrieve(task_id) + (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) + annotation_job_metas = [ + api_client.jobs_api.retrieve_data_meta(job.id)[0] + for job in get_paginated_collection( + api_client.jobs_api.list_endpoint, task_id=task_id, type="annotation" + ) + ] + gt_job_metas = [ + api_client.jobs_api.retrieve_data_meta(job.id)[0] + for job in get_paginated_collection( + api_client.jobs_api.list_endpoint, task_id=task_id, type="ground_truth" + ) + ] + + assert len(gt_job_metas) == 1 + + assert task.segment_size == segment_size + assert task.size == resulting_task_size + assert task_meta.size == resulting_task_size + + frame_step = int((gt_job_metas[0].frame_filter or "step=1").split("=")[1]) + validation_frames = [ + abs_frame_id + for abs_frame_id in range( + gt_job_metas[0].start_frame, + gt_job_metas[0].stop_frame + 1, + frame_step, + ) + if abs_frame_id in gt_job_metas[0].included_frames + ] + + if frame_selection_method == "random_per_job": + # each job must have the specified number of validation frames + for job_meta in annotation_job_metas: + assert ( + len( + set( + range(job_meta.start_frame, job_meta.stop_frame + 1, frame_step) + ).intersection(validation_frames) + ) == validation_per_job_count ) + else: + assert len(validation_frames) == validation_frames_count class _SourceDataType(str, Enum): From cbd75cdfb01c4bf142814e6ca3d54617b3336d4a Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 13 Sep 2024 19:46:07 +0300 Subject: [PATCH 150/227] Update data migration --- ...d_and_more.py => 0084_honeypot_support.py} | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) rename cvat/apps/engine/migrations/{0084_image_is_placeholder_image_real_frame_id_and_more.py => 0084_honeypot_support.py} (67%) diff --git a/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py b/cvat/apps/engine/migrations/0084_honeypot_support.py similarity index 67% rename from cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py rename to cvat/apps/engine/migrations/0084_honeypot_support.py index 36e7b4cff36e..2e3e021f27fb 100644 --- a/cvat/apps/engine/migrations/0084_image_is_placeholder_image_real_frame_id_and_more.py +++ b/cvat/apps/engine/migrations/0084_honeypot_support.py @@ -1,9 +1,62 @@ # Generated by Django 4.2.15 on 2024-09-13 11:34 +from typing import Collection import cvat.apps.engine.models from django.db import migrations, models import django.db.models.deletion +def get_frame_step(db_data) -> int: + v = db_data.frame_filter or "step=1" + return int(v.split("=")[-1]) + +def get_rel_frame(abs_frame: int, db_data) -> int: + data_start_frame = db_data.start_frame + step = get_frame_step(db_data) + return (abs_frame - data_start_frame) // step + +def get_segment_rel_frame_set(db_segment) -> Collection[int]: + db_data = db_segment.task.data + data_start_frame = db_data.start_frame + data_stop_frame = db_data.stop_frame + step = get_frame_step(db_data) + frame_range = range( + data_start_frame + db_segment.start_frame * step, + min(data_start_frame + db_segment.stop_frame * step, data_stop_frame) + step, + step + ) + + if db_segment.type == 'range': + frame_set = frame_range + elif db_segment.type == 'specific_frames': + frame_set = set(frame_range).intersection(db_segment.frames or []) + else: + assert False + + return sorted(get_rel_frame(abs_frame, db_data) for abs_frame in frame_set) + + +def init_validation_layout_in_tasks_with_gt_job(apps, schema_editor): + Job = apps.get_model("engine", "Job") + ValidationLayout = apps.get_model("engine", "ValidationLayout") + + gt_jobs = ( + Job.objects + .filter(type="ground_truth") + .select_related('segment', 'segment__task', 'segment__task__data') + .iterator(chunk_size=100) + ) + + validation_layouts = [] + for gt_job in gt_jobs: + validation_layout = ValidationLayout( + task_data=gt_job.segment.task.data, + mode="gt", + frames=get_segment_rel_frame_set(gt_job.segment) + ) + validation_layouts.append(validation_layout) + + ValidationLayout.objects.bulk_create(validation_layouts, batch_size=100) + class Migration(migrations.Migration): @@ -106,4 +159,5 @@ class Migration(migrations.Migration): ), ], ), + migrations.RunPython(init_validation_layout_in_tasks_with_gt_job) ] From 7f2e42e7b79cf1c64302bdcbfdd69778a287eca4 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 13 Sep 2024 21:44:24 +0300 Subject: [PATCH 151/227] Update gt job removal --- cvat/apps/engine/views.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 3ae891f6b554..750497f28477 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -1750,21 +1750,25 @@ def perform_create(self, serializer): # Required for the extra summary information added in the queryset serializer.instance = self.get_queryset().get(pk=serializer.instance.pk) + @transaction.atomic def perform_destroy(self, instance): if instance.type != JobType.GROUND_TRUTH: raise ValidationError("Only ground truth jobs can be removed") - if ( - validation_layout := getattr(instance.segment.task.data, 'validation_layout', None) and - validation_layout.mode == models.ValidationMode.GT_POOL - ): + validation_layout: Optional[models.ValidationLayout] = getattr( + instance.segment.task.data, 'validation_layout', None + ) + if (validation_layout and validation_layout.mode == models.ValidationMode.GT_POOL): raise ValidationError( 'GT jobs cannot be removed when task validation mode is "{}"'.format( models.ValidationMode.GT_POOL ) ) - return super().perform_destroy(instance) + super().perform_destroy(instance) + + if validation_layout: + validation_layout.delete() # UploadMixin method def get_upload_dir(self): From 6cc0012c2d1393ab933c3b17cc271b2236c0e57b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 13 Sep 2024 22:54:58 +0300 Subject: [PATCH 152/227] Sort gt job frames regardless --- cvat/apps/engine/task.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index af76fd674df3..0963c75b750f 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1227,6 +1227,14 @@ def _update_status(msg: str) -> None: case _: assert False + # Even though the sorting is random overall, + # it's convenient to be able to reasonably navigate in the GT job + pool_frames = sort( + pool_frames, + sorting_method=models.SortingMethod.NATURAL, + func=lambda frame: images[frame].path, + ) + # 2. distribute pool frames if frames_per_job_count := validation_params.get("frames_per_job_count"): if len(pool_frames) < frames_per_job_count and validation_params.get("frame_count"): From 5322e82126f444e338027acefec28657a514ee9c Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 13 Sep 2024 23:13:03 +0300 Subject: [PATCH 153/227] Add test task with honeypots --- tests/python/shared/assets/annotations.json | 4677 +++++++++++------ .../shared/assets/cvat_db/cvat_data.tar.bz2 | Bin 169947 -> 181752 bytes tests/python/shared/assets/cvat_db/data.json | 1478 +++++- tests/python/shared/assets/jobs.json | 140 +- tests/python/shared/assets/labels.json | 24 +- tests/python/shared/assets/projects.json | 2 +- .../shared/assets/quality_settings.json | 21 +- tests/python/shared/assets/tasks.json | 48 +- tests/python/shared/assets/users.json | 2 +- 9 files changed, 4566 insertions(+), 1826 deletions(-) diff --git a/tests/python/shared/assets/annotations.json b/tests/python/shared/assets/annotations.json index 67241270fe5c..837686e17c77 100644 --- a/tests/python/shared/assets/annotations.json +++ b/tests/python/shared/assets/annotations.json @@ -4625,25 +4625,28 @@ "tags": [], "tracks": [], "version": 0 - } - }, - "task": { - "2": { + }, + "35": { "shapes": [ { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame1 n1" + } + ], "elements": [], "frame": 0, "group": 0, - "id": 1, - "label_id": 3, + "id": 177, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 223.39453125, - 226.0751953125, - 513.7663269042969, - 377.9619903564453 + 19.650000000003274, + 13.100000000002183, + 31.850000000004002, + 18.900000000001455 ], "rotation": 0.0, "source": "manual", @@ -4651,216 +4654,216 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame2 n1" + } + ], "elements": [], "frame": 1, "group": 0, - "id": 2, - "label_id": 3, + "id": 178, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 63.0791015625, - 139.75390625, - 132.19337349397574, - 112.3867469879533, - 189.71144578313397, - 159.23614457831354, - 191.1030120481937, - 246.9048192771097, - 86.73554216867524, - 335.5012048192784, - 32.00060240964012, - 250.15180722891637 + 18.650000000003274, + 10.500000000001819, + 28.650000000003274, + 15.200000000002547 ], "rotation": 0.0, "source": "manual", - "type": "polygon", + "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame2 n2" + } + ], "elements": [], "frame": 1, "group": 0, - "id": 3, - "label_id": 4, + "id": 179, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 83.0244140625, - 216.75390625, - 112.24759036144678, - 162.48313253012202, - 167.44638554216908, - 183.35662650602535, - 149.35602409638705, - 252.0072289156633, - 84.41626506024113, - 292.8265060240974, - 72.81987951807241, - 258.9650602409638 + 18.850000000002183, + 19.50000000000182, + 27.05000000000291, + 24.900000000001455 ], "rotation": 0.0, "source": "manual", - "type": "polygon", + "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame6 n1" + } + ], "elements": [], - "frame": 2, + "frame": 5, "group": 0, - "id": 4, - "label_id": 3, + "id": 180, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 24.443359375, - 107.2275390625, - 84.91109877913368, - 61.125083240844106, - 169.4316315205324, - 75.1561598224198, - 226.5581576026634, - 113.90865704772477, - 240.5892341842391, - 205.77880133185317, - 210.52264150943483, - 270.9230854605994 + 26.25000000000182, + 16.50000000000182, + 40.95000000000255, + 23.900000000001455 ], "rotation": 0.0, "source": "manual", - "type": "polyline", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "36": { + "shapes": [ + { + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame1 n1" + } + ], + "elements": [], + "frame": 8, + "group": 0, + "id": 181, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 14.650000000003274, + 10.000000000001819, + 25.750000000003638, + 17.30000000000109 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame1 n2" + } + ], "elements": [], - "frame": 22, + "frame": 8, "group": 0, - "id": 5, - "label_id": 3, + "id": 182, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 148.94921875, - 285.6865234375, - 313.515094339622, - 400.32830188679145, - 217.36415094339463, - 585.2339622641503, - 64.81698113207494, - 499.25283018867776 + 30.350000000002183, + 18.700000000002547, + 43.05000000000291, + 26.400000000003274 ], "rotation": 0.0, "source": "manual", - "type": "points", + "type": "rectangle", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "5": { - "shapes": [ + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame2 n1" + } + ], "elements": [], - "frame": 0, + "frame": 9, "group": 0, - "id": 29, - "label_id": 9, + "id": 183, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 364.0361328125, - 528.87890625, - 609.5286041189956, - 586.544622425632, - 835.2494279176244, - 360.0000000000018, - 543.6247139588122, - 175.4691075514893, - 326.9656750572103, - 192.76887871853796, - 244.58581235698148, - 319.63386727689067 + 9.200000000002547, + 34.35000000000218, + 21.900000000003274, + 38.55000000000291 ], "rotation": 0.0, "source": "manual", - "type": "polygon", + "type": "rectangle", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "6": { - "shapes": [], - "tags": [], - "tracks": [], - "version": 0 - }, - "7": { - "shapes": [ + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame2 n2" + } + ], "elements": [], - "frame": 0, + "frame": 9, "group": 0, - "id": 27, - "label_id": 11, + "id": 184, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 448.3779296875, - 356.4892578125, - 438.2558352402775, - 761.3861556064112, - 744.1780320366161, - 319.37356979405195, - 446.1288329519466, - 163.03832951945333 + 40.900390625, + 29.0498046875, + 48.80000000000291, + 30.350000000002183, + 45.10000000000218, + 39.25000000000182, + 45.70000000000255, + 24.450000000002547 ], "rotation": 0.0, "source": "manual", - "type": "polygon", + "type": "points", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "8": { - "shapes": [ + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame5 n1" + } + ], "elements": [], - "frame": 0, + "frame": 12, "group": 0, - "id": 30, - "label_id": 13, + "id": 185, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 440.0439453125, - 84.0791015625, - 71.83311938382576, - 249.81514762516053, - 380.4441591784325, - 526.585365853658, - 677.6251604621302, - 260.42875481386363, - 629.4557124518615, - 127.35044929396645 + 16.791015625, + 32.8505859375, + 27.858705213058784, + 37.01258996859542, + 21.633141273523506, + 39.77950727505595 ], "rotation": 0.0, "source": "manual", - "type": "polygon", + "type": "points", "z_order": 0 } ], @@ -4868,478 +4871,381 @@ "tracks": [], "version": 0 }, - "9": { + "37": { "shapes": [ { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame1 n1" + } + ], "elements": [], - "frame": 0, + "frame": 16, "group": 0, - "id": 31, - "label_id": 6, + "id": 186, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 65.6189987163034, - 100.96585365853753, - 142.12734274711147, - 362.6243902439037 + 29.0498046875, + 14.2998046875, + 30.350000000002183, + 22.00000000000182, + 20.650000000003274, + 21.600000000002183, + 20.650000000003274, + 11.30000000000291 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame2 n1" + } + ], "elements": [], - "frame": 15, + "frame": 17, "group": 0, - "id": 41, - "label_id": 6, + "id": 187, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 53.062929061787145, - 301.6390160183091, - 197.94851258581548, - 763.3266590389048 + 51.2001953125, + 10.900390625, + 56.60000000000218, + 15.700000000002547, + 48.400000000003274, + 20.400000000003274 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 }, { "attributes": [ { - "spec_id": 1, - "value": "mazda" + "spec_id": 15, + "value": "j3 frame5 n1" } ], "elements": [], - "frame": 16, + "frame": 20, "group": 0, - "id": 42, - "label_id": 5, + "id": 188, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 172.0810546875, - 105.990234375, - 285.97262095255974, - 138.40000000000146 + 37.2998046875, + 7.7001953125, + 42.400000000003274, + 11.900000000003274, + 35.80000000000291, + 17.200000000002547, + 28.400000000003274, + 8.80000000000291, + 37.400000000003274, + 12.100000000002183 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "11": { - "shapes": [ + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame5 n2" + } + ], "elements": [], - "frame": 0, + "frame": 20, "group": 0, - "id": 33, - "label_id": 7, + "id": 189, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 100.14453125, - 246.03515625, - 408.8692551505537, - 327.5483359746413, - 588.5839936608554, - 289.0380348652925, - 623.8851030110927, - 183.77654516640177, - 329.2812995245622, - 71.45483359746322 + 17.600000000002183, + 14.900000000003274, + 27.200000000002547, + 21.600000000004002 ], "rotation": 0.0, "source": "manual", - "type": "polyline", + "type": "rectangle", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "13": { - "shapes": [ + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame6 n1" + } + ], "elements": [], - "frame": 0, + "frame": 21, "group": 0, - "id": 34, - "label_id": 16, + "id": 190, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 106.361328125, - 85.150390625, - 240.083984375, - 241.263671875 + 43.15465253950242, + 24.59525439814206, + 55.395253809205315, + 35.071444674014856 ], - "rotation": 45.9, + "rotation": 0.0, "source": "manual", "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame7 n1" + } + ], "elements": [], - "frame": 1, + "frame": 22, "group": 0, - "id": 35, - "label_id": 16, + "id": 191, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 414.29522752496996, - 124.8035516093205, - 522.2641509433943, - 286.75693673695605 + 38.50000000000182, + 9.600000000002183, + 51.80000000000109, + 17.100000000002183 ], "rotation": 0.0, "source": "manual", "type": "rectangle", "z_order": 0 - } - ], - "tags": [ - { - "attributes": [], - "frame": 2, - "group": 0, - "id": 1, - "label_id": 17, - "source": "manual" }, { - "attributes": [], - "frame": 3, + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame7 n2" + } + ], + "elements": [], + "frame": 22, "group": 0, - "id": 2, - "label_id": 16, - "source": "manual" + "id": 192, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 52.10000000000218, + 17.30000000000291, + 59.400000000001455, + 21.500000000003638 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 } ], + "tags": [], "tracks": [], "version": 0 }, - "14": { + "38": { "shapes": [ { "attributes": [ { - "spec_id": 2, - "value": "white" + "spec_id": 15, + "value": "gt frame1 n1" } ], - "elements": [ - { - "attributes": [ - { - "spec_id": 3, - "value": "val1" - } - ], - "frame": 0, - "group": 0, - "id": 39, - "label_id": 25, - "occluded": false, - "outside": false, - "points": [ - 259.91862203681984, - 67.8260869565238 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, + "elements": [], + "frame": 23, + "group": 0, + "id": 169, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 17.650000000003274, + 11.30000000000291, + 30.55000000000291, + 21.700000000002547 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 40, - "label_id": 26, - "occluded": false, - "outside": false, - "points": [ - 283.65217391304554, - 276.52173913043686 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, + "spec_id": 15, + "value": "gt frame2 n2" + } + ], + "elements": [], + "frame": 24, + "group": 0, + "id": 170, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 18.850000000002183, + 12.000000000001819, + 25.850000000002183, + 19.50000000000182 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 37, - "label_id": 23, - "occluded": false, - "outside": false, - "points": [ - 135.8260869565238, - 118.10276296228554 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, + "spec_id": 15, + "value": "gt frame3 n3" + } + ], + "elements": [], + "frame": 24, + "group": 0, + "id": 171, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 26.150000000003274, + 25.00000000000182, + 34.150000000003274, + 34.50000000000182 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 38, - "label_id": 24, - "occluded": false, - "outside": false, - "points": [ - 172.10450871201368, - 274.6245183225243 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 + "spec_id": 15, + "value": "gt frame3 n1" } ], - "frame": 0, + "elements": [], + "frame": 25, "group": 0, - "id": 36, - "label_id": 22, + "id": 172, + "label_id": 75, "occluded": false, "outside": false, - "points": [], + "points": [ + 24.600000000002183, + 11.500000000001819, + 37.10000000000218, + 18.700000000002547 + ], "rotation": 0.0, - "source": "manual", - "type": "skeleton", + "source": "Ground truth", + "type": "rectangle", "z_order": 0 - } - ], - "tags": [], - "tracks": [ + }, { "attributes": [ { - "spec_id": 2, - "value": "white" + "spec_id": 15, + "value": "gt frame5 n1" } ], - "elements": [ + "elements": [], + "frame": 27, + "group": 0, + "id": 175, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 17.863216443472993, + 36.43614886308387, + 41.266725327279346, + 42.765472201610464 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 2, - "label_id": 23, - "shapes": [ - { - "attributes": [], - "frame": 0, - "id": 2, - "occluded": false, - "outside": false, - "points": [ - 381.9130434782637, - 355.0592829431864 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 3, - "id": 6, - "occluded": false, - "outside": false, - "points": [ - 137.0966796875, - 156.11214469590232 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } - ], - "source": "manual" - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 3, - "label_id": 24, - "shapes": [ - { - "attributes": [], - "frame": 0, - "id": 3, - "occluded": false, - "outside": false, - "points": [ - 461.9389738212561, - 583.320176176868 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 3, - "id": 7, - "occluded": false, - "outside": false, - "points": [ - 217.12261003049207, - 384.3730379295848 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } - ], - "source": "manual" - }, - { - "attributes": [ - { - "spec_id": 3, - "value": "val1" - } - ], - "frame": 0, - "group": 0, - "id": 4, - "label_id": 25, - "shapes": [ - { - "attributes": [], - "frame": 0, - "id": 4, - "occluded": false, - "outside": false, - "points": [ - 655.6465767436227, - 281.7391304347839 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 3, - "id": 8, - "occluded": false, - "outside": false, - "points": [ - 410.83021295285835, - 82.7919921875 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } - ], - "source": "manual" - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 5, - "label_id": 26, - "shapes": [ - { - "attributes": [], - "frame": 0, - "id": 5, - "occluded": false, - "outside": false, - "points": [ - 708.000000000003, - 586.0869565217404 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 3, - "id": 9, - "occluded": false, - "outside": false, - "points": [ - 463.1836362092399, - 387.13981827445605 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } - ], - "source": "manual" + "spec_id": 15, + "value": "gt frame5 n2" } ], - "frame": 0, + "elements": [], + "frame": 27, "group": 0, - "id": 1, - "label_id": 22, - "shapes": [ - { - "attributes": [], - "frame": 0, - "id": 1, - "occluded": false, - "outside": false, - "points": [], - "rotation": 0.0, - "type": "skeleton", - "z_order": 0 - } + "id": 176, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 34.349609375, + 52.806640625, + 27.086274131672326, + 63.1830161588623, + 40.229131337355284, + 67.44868033965395, + 48.87574792004307, + 59.03264019917333, + 45.53238950807099, + 53.3835173651496 ], - "source": "manual" + "rotation": 0.0, + "source": "Ground truth", + "type": "polygon", + "z_order": 0 } ], + "tags": [], + "tracks": [], "version": 0 - }, - "15": { + } + }, + "task": { + "2": { "shapes": [ { "attributes": [], "elements": [], "frame": 0, "group": 0, - "id": 44, - "label_id": 29, + "id": 1, + "label_id": 3, "occluded": false, "outside": false, "points": [ - 479.97322623828586, - 408.0053547523421, - 942.6238286479238, - 513.3868808567604 + 223.39453125, + 226.0751953125, + 513.7663269042969, + 377.9619903564453 ], "rotation": 0.0, "source": "manual", @@ -5349,176 +5255,144 @@ { "attributes": [], "elements": [], - "frame": 0, + "frame": 1, "group": 0, - "id": 43, - "label_id": 30, + "id": 2, + "label_id": 3, "occluded": false, "outside": false, "points": [ - 120.81927710843593, - 213.52074966532928, - 258.7576974564945, - 643.614457831327 + 63.0791015625, + 139.75390625, + 132.19337349397574, + 112.3867469879533, + 189.71144578313397, + 159.23614457831354, + 191.1030120481937, + 246.9048192771097, + 86.73554216867524, + 335.5012048192784, + 32.00060240964012, + 250.15180722891637 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "17": { - "shapes": [ + }, { "attributes": [], "elements": [], - "frame": 0, + "frame": 1, "group": 0, - "id": 47, - "label_id": 38, + "id": 3, + "label_id": 4, "occluded": false, "outside": false, "points": [ - 106.361328125, - 85.150390625, - 240.083984375, - 241.263671875 + 83.0244140625, + 216.75390625, + 112.24759036144678, + 162.48313253012202, + 167.44638554216908, + 183.35662650602535, + 149.35602409638705, + 252.0072289156633, + 84.41626506024113, + 292.8265060240974, + 72.81987951807241, + 258.9650602409638 ], - "rotation": 45.9, + "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 }, { "attributes": [], "elements": [], - "frame": 1, + "frame": 2, "group": 0, - "id": 48, - "label_id": 38, + "id": 4, + "label_id": 3, "occluded": false, "outside": false, "points": [ - 414.29522752496996, - 124.8035516093205, - 522.2641509433943, - 286.75693673695605 + 24.443359375, + 107.2275390625, + 84.91109877913368, + 61.125083240844106, + 169.4316315205324, + 75.1561598224198, + 226.5581576026634, + 113.90865704772477, + 240.5892341842391, + 205.77880133185317, + 210.52264150943483, + 270.9230854605994 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polyline", "z_order": 0 - } - ], - "tags": [ - { - "attributes": [], - "frame": 2, - "group": 0, - "id": 5, - "label_id": 39, - "source": "manual" }, { "attributes": [], - "frame": 3, + "elements": [], + "frame": 22, "group": 0, - "id": 6, - "label_id": 38, - "source": "manual" + "id": 5, + "label_id": 3, + "occluded": false, + "outside": false, + "points": [ + 148.94921875, + 285.6865234375, + 313.515094339622, + 400.32830188679145, + 217.36415094339463, + 585.2339622641503, + 64.81698113207494, + 499.25283018867776 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 } ], + "tags": [], "tracks": [], "version": 0 }, - "18": { + "5": { "shapes": [ { "attributes": [], - "elements": [ - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 52, - "label_id": 49, - "occluded": false, - "outside": false, - "points": [ - 326.2062528608664, - 107.42983682983868 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 50, - "label_id": 47, - "occluded": false, - "outside": false, - "points": [ - 136.46993006993034, - 138.72697241590762 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 51, - "label_id": 48, - "occluded": false, - "outside": false, - "points": [ - 192.9001336620433, - 421.9659673659692 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 53, - "label_id": 50, - "occluded": false, - "outside": false, - "points": [ - 412.07832167832197, - 337.46374412038085 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - } - ], + "elements": [], "frame": 0, "group": 0, - "id": 49, - "label_id": 46, + "id": 29, + "label_id": 9, "occluded": false, "outside": false, - "points": [], + "points": [ + 364.0361328125, + 528.87890625, + 609.5286041189956, + 586.544622425632, + 835.2494279176244, + 360.0000000000018, + 543.6247139588122, + 175.4691075514893, + 326.9656750572103, + 192.76887871853796, + 244.58581235698148, + 319.63386727689067 + ], "rotation": 0.0, "source": "manual", - "type": "skeleton", + "type": "polygon", "z_order": 0 } ], @@ -5526,56 +5400,69 @@ "tracks": [], "version": 0 }, - "19": { + "6": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "7": { "shapes": [ { - "attributes": [ - { - "spec_id": 7, - "value": "non-default" - } - ], + "attributes": [], "elements": [], "frame": 0, "group": 0, - "id": 54, - "label_id": 51, + "id": 27, + "label_id": 11, "occluded": false, "outside": false, "points": [ - 244.32906271072352, - 57.53054619015711, - 340.34389750505943, - 191.28914362778414 + 448.3779296875, + 356.4892578125, + 438.2558352402775, + 761.3861556064112, + 744.1780320366161, + 319.37356979405195, + 446.1288329519466, + 163.03832951945333 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 - }, + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "8": { + "shapes": [ { - "attributes": [ - { - "spec_id": 8, - "value": "black" - } - ], + "attributes": [], "elements": [], "frame": 0, "group": 0, - "id": 55, - "label_id": 52, + "id": 30, + "label_id": 13, "occluded": false, "outside": false, "points": [ - 424.4396493594086, - 86.6660822656795, - 664.8078219824692, - 251.54672960215976 + 440.0439453125, + 84.0791015625, + 71.83311938382576, + 249.81514762516053, + 380.4441591784325, + 526.585365853658, + 677.6251604621302, + 260.42875481386363, + 629.4557124518615, + 127.35044929396645 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 } ], @@ -5583,27 +5470,22 @@ "tracks": [], "version": 0 }, - "20": { + "9": { "shapes": [ { - "attributes": [ - { - "spec_id": 9, - "value": "non-default" - } - ], + "attributes": [], "elements": [], "frame": 0, "group": 0, - "id": 56, - "label_id": 53, + "id": 31, + "label_id": 6, "occluded": false, "outside": false, "points": [ - 35.913636363637124, - 80.58636363636288, - 94.8227272727272, - 170.58636363636288 + 65.6189987163034, + 100.96585365853753, + 142.12734274711147, + 362.6243902439037 ], "rotation": 0.0, "source": "manual", @@ -5611,24 +5493,44 @@ "z_order": 0 }, { - "attributes": [ - { - "spec_id": 10, - "value": "black" - } - ], + "attributes": [], "elements": [], - "frame": 0, + "frame": 15, "group": 0, - "id": 57, - "label_id": 54, + "id": 41, + "label_id": 6, "occluded": false, "outside": false, "points": [ - 190.95909090909117, - 100.22272727272684, - 297.7318181818191, - 209.8590909090908 + 53.062929061787145, + 301.6390160183091, + 197.94851258581548, + 763.3266590389048 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 1, + "value": "mazda" + } + ], + "elements": [], + "frame": 16, + "group": 0, + "id": 42, + "label_id": 5, + "occluded": false, + "outside": false, + "points": [ + 172.0810546875, + 105.990234375, + 285.97262095255974, + 138.40000000000146 ], "rotation": 0.0, "source": "manual", @@ -5640,22 +5542,129 @@ "tracks": [], "version": 0 }, - "21": { + "11": { + "shapes": [ + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 33, + "label_id": 7, + "occluded": false, + "outside": false, + "points": [ + 100.14453125, + 246.03515625, + 408.8692551505537, + 327.5483359746413, + 588.5839936608554, + 289.0380348652925, + 623.8851030110927, + 183.77654516640177, + 329.2812995245622, + 71.45483359746322 + ], + "rotation": 0.0, + "source": "manual", + "type": "polyline", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "13": { "shapes": [ { "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 34, + "label_id": 16, + "occluded": false, + "outside": false, + "points": [ + 106.361328125, + 85.150390625, + 240.083984375, + 241.263671875 + ], + "rotation": 45.9, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [], + "elements": [], + "frame": 1, + "group": 0, + "id": 35, + "label_id": 16, + "occluded": false, + "outside": false, + "points": [ + 414.29522752496996, + 124.8035516093205, + 522.2641509433943, + 286.75693673695605 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [ + { + "attributes": [], + "frame": 2, + "group": 0, + "id": 1, + "label_id": 17, + "source": "manual" + }, + { + "attributes": [], + "frame": 3, + "group": 0, + "id": 2, + "label_id": 16, + "source": "manual" + } + ], + "tracks": [], + "version": 0 + }, + "14": { + "shapes": [ + { + "attributes": [ + { + "spec_id": 2, + "value": "white" + } + ], "elements": [ { - "attributes": [], - "frame": 6, + "attributes": [ + { + "spec_id": 3, + "value": "val1" + } + ], + "frame": 0, "group": 0, - "id": 62, - "label_id": 61, + "id": 39, + "label_id": 25, "occluded": false, "outside": false, "points": [ - 155.7276059652392, - 30.260833689097126 + 259.91862203681984, + 67.8260869565238 ], "rotation": 0.0, "source": "manual", @@ -5664,15 +5673,15 @@ }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 60, - "label_id": 59, + "id": 40, + "label_id": 26, "occluded": false, "outside": false, "points": [ - 103.73647295885894, - 51.085564225393554 + 283.65217391304554, + 276.52173913043686 ], "rotation": 0.0, "source": "manual", @@ -5681,15 +5690,15 @@ }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 61, - "label_id": 60, + "id": 37, + "label_id": 23, "occluded": false, "outside": false, "points": [ - 125.86783527366472, - 101.86367376801435 + 135.8260869565238, + 118.10276296228554 ], "rotation": 0.0, "source": "manual", @@ -5698,15 +5707,15 @@ }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 63, - "label_id": 62, + "id": 38, + "label_id": 24, "occluded": false, "outside": false, "points": [ - 199.28775272671066, - 114.13029555429613 + 172.10450871201368, + 274.6245183225243 ], "rotation": 0.0, "source": "manual", @@ -5714,10 +5723,10 @@ "z_order": 0 } ], - "frame": 6, + "frame": 0, "group": 0, - "id": 59, - "label_id": 58, + "id": 36, + "label_id": 22, "occluded": false, "outside": false, "points": [], @@ -5725,49 +5734,34 @@ "source": "manual", "type": "skeleton", "z_order": 0 - }, - { - "attributes": [], - "elements": [], - "frame": 6, - "group": 0, - "id": 58, - "label_id": 57, - "occluded": false, - "outside": false, - "points": [ - 42.63157931421483, - 51.228199155397306, - 106.13274329786509, - 138.0929989443539 - ], - "rotation": 0.0, - "source": "manual", - "type": "rectangle", - "z_order": 0 } ], "tags": [], "tracks": [ { - "attributes": [], + "attributes": [ + { + "spec_id": 2, + "value": "white" + } + ], "elements": [ { "attributes": [], "frame": 0, "group": 0, - "id": 7, - "label_id": 59, + "id": 2, + "label_id": 23, "shapes": [ { "attributes": [], "frame": 0, - "id": 11, + "id": 2, "occluded": false, - "outside": true, + "outside": false, "points": [ - 230.39103314621025, - 149.98846070356873 + 381.9130434782637, + 355.0592829431864 ], "rotation": 0.0, "type": "points", @@ -5776,26 +5770,12 @@ { "attributes": [], "frame": 3, - "id": 12, + "id": 6, "occluded": false, "outside": false, "points": [ - 230.39103314621025, - 149.98846070356873 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 6, - "id": 12, - "occluded": false, - "outside": true, - "points": [ - 230.39103314621025, - 149.98846070356873 + 137.0966796875, + 156.11214469590232 ], "rotation": 0.0, "type": "points", @@ -5808,18 +5788,18 @@ "attributes": [], "frame": 0, "group": 0, - "id": 8, - "label_id": 60, + "id": 3, + "label_id": 24, "shapes": [ { "attributes": [], "frame": 0, - "id": 13, + "id": 3, "occluded": false, "outside": false, "points": [ - 292.80597636674844, - 284.1818841927473 + 461.9389738212561, + 583.320176176868 ], "rotation": 0.0, "type": "points", @@ -5827,13 +5807,13 @@ }, { "attributes": [], - "frame": 6, - "id": 13, + "frame": 3, + "id": 7, "occluded": false, - "outside": true, + "outside": false, "points": [ - 292.80597636674844, - 284.1818841927473 + 217.12261003049207, + 384.3730379295848 ], "rotation": 0.0, "type": "points", @@ -5843,21 +5823,26 @@ "source": "manual" }, { - "attributes": [], + "attributes": [ + { + "spec_id": 3, + "value": "val1" + } + ], "frame": 0, "group": 0, - "id": 9, - "label_id": 61, + "id": 4, + "label_id": 25, "shapes": [ { "attributes": [], "frame": 0, - "id": 14, + "id": 4, "occluded": false, "outside": false, "points": [ - 377.016603158851, - 94.95407858346152 + 655.6465767436227, + 281.7391304347839 ], "rotation": 0.0, "type": "points", @@ -5865,13 +5850,13 @@ }, { "attributes": [], - "frame": 6, - "id": 14, + "frame": 3, + "id": 8, "occluded": false, - "outside": true, + "outside": false, "points": [ - 377.016603158851, - 94.95407858346152 + 410.83021295285835, + 82.7919921875 ], "rotation": 0.0, "type": "points", @@ -5884,18 +5869,18 @@ "attributes": [], "frame": 0, "group": 0, - "id": 10, - "label_id": 62, + "id": 5, + "label_id": 26, "shapes": [ { "attributes": [], "frame": 0, - "id": 15, + "id": 5, "occluded": false, "outside": false, "points": [ - 499.86507710826913, - 316.59939612801213 + 708.000000000003, + 586.0869565217404 ], "rotation": 0.0, "type": "points", @@ -5903,13 +5888,13 @@ }, { "attributes": [], - "frame": 6, - "id": 15, + "frame": 3, + "id": 9, "occluded": false, - "outside": true, + "outside": false, "points": [ - 499.86507710826913, - 316.59939612801213 + 463.1836362092399, + 387.13981827445605 ], "rotation": 0.0, "type": "points", @@ -5921,158 +5906,1351 @@ ], "frame": 0, "group": 0, - "id": 6, - "label_id": 58, + "id": 1, + "label_id": 22, "shapes": [ { "attributes": [], "frame": 0, - "id": 10, + "id": 1, "occluded": false, "outside": false, "points": [], "rotation": 0.0, "type": "skeleton", "z_order": 0 - }, - { - "attributes": [], - "frame": 6, - "id": 10, - "occluded": false, - "outside": true, - "points": [], - "rotation": 0.0, - "type": "skeleton", - "z_order": 0 } ], "source": "manual" + } + ], + "version": 0 + }, + "15": { + "shapes": [ + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 44, + "label_id": 29, + "occluded": false, + "outside": false, + "points": [ + 479.97322623828586, + 408.0053547523421, + 942.6238286479238, + 513.3868808567604 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 43, + "label_id": 30, + "occluded": false, + "outside": false, + "points": [ + 120.81927710843593, + 213.52074966532928, + 258.7576974564945, + 643.614457831327 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "17": { + "shapes": [ + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 47, + "label_id": 38, + "occluded": false, + "outside": false, + "points": [ + 106.361328125, + 85.150390625, + 240.083984375, + 241.263671875 + ], + "rotation": 45.9, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [], + "elements": [], + "frame": 1, + "group": 0, + "id": 48, + "label_id": 38, + "occluded": false, + "outside": false, + "points": [ + 414.29522752496996, + 124.8035516093205, + 522.2641509433943, + 286.75693673695605 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [ + { + "attributes": [], + "frame": 2, + "group": 0, + "id": 5, + "label_id": 39, + "source": "manual" }, + { + "attributes": [], + "frame": 3, + "group": 0, + "id": 6, + "label_id": 38, + "source": "manual" + } + ], + "tracks": [], + "version": 0 + }, + "18": { + "shapes": [ { "attributes": [], "elements": [ { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 12, - "label_id": 59, - "shapes": [ - { - "attributes": [], - "frame": 6, - "id": 17, - "occluded": false, - "outside": false, - "points": [ - 92.95325643333308, - 129.2954675940839 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } + "id": 52, + "label_id": 49, + "occluded": false, + "outside": false, + "points": [ + 326.2062528608664, + 107.42983682983868 ], - "source": "manual" + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 13, - "label_id": 60, - "shapes": [ - { - "attributes": [], - "frame": 6, - "id": 18, - "occluded": false, - "outside": false, - "points": [ - 133.81649280769233, - 195.4883603907146 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } + "id": 50, + "label_id": 47, + "occluded": false, + "outside": false, + "points": [ + 136.46993006993034, + 138.72697241590762 ], - "source": "manual" + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 14, - "label_id": 61, - "shapes": [ - { - "attributes": [], - "frame": 6, - "id": 19, - "occluded": false, - "outside": false, - "points": [ - 188.94942364574058, - 102.14894385926891 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } + "id": 51, + "label_id": 48, + "occluded": false, + "outside": false, + "points": [ + 192.9001336620433, + 421.9659673659692 ], - "source": "manual" + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 15, - "label_id": 62, - "shapes": [ - { - "attributes": [], - "frame": 6, - "id": 20, - "occluded": false, - "outside": false, - "points": [ - 269.3786601426267, - 211.47877807640333 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } + "id": 53, + "label_id": 50, + "occluded": false, + "outside": false, + "points": [ + 412.07832167832197, + 337.46374412038085 ], - "source": "manual" + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 } ], - "frame": 6, + "frame": 0, "group": 0, - "id": 11, - "label_id": 58, - "shapes": [ + "id": 49, + "label_id": 46, + "occluded": false, + "outside": false, + "points": [], + "rotation": 0.0, + "source": "manual", + "type": "skeleton", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "19": { + "shapes": [ + { + "attributes": [ + { + "spec_id": 7, + "value": "non-default" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 54, + "label_id": 51, + "occluded": false, + "outside": false, + "points": [ + 244.32906271072352, + 57.53054619015711, + 340.34389750505943, + 191.28914362778414 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 8, + "value": "black" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 55, + "label_id": 52, + "occluded": false, + "outside": false, + "points": [ + 424.4396493594086, + 86.6660822656795, + 664.8078219824692, + 251.54672960215976 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "20": { + "shapes": [ + { + "attributes": [ + { + "spec_id": 9, + "value": "non-default" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 56, + "label_id": 53, + "occluded": false, + "outside": false, + "points": [ + 35.913636363637124, + 80.58636363636288, + 94.8227272727272, + 170.58636363636288 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 10, + "value": "black" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 57, + "label_id": 54, + "occluded": false, + "outside": false, + "points": [ + 190.95909090909117, + 100.22272727272684, + 297.7318181818191, + 209.8590909090908 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "21": { + "shapes": [ + { + "attributes": [], + "elements": [ { "attributes": [], "frame": 6, - "id": 16, + "group": 0, + "id": 62, + "label_id": 61, "occluded": false, "outside": false, - "points": [], + "points": [ + 155.7276059652392, + 30.260833689097126 + ], "rotation": 0.0, - "type": "skeleton", + "source": "manual", + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 60, + "label_id": 59, + "occluded": false, + "outside": false, + "points": [ + 103.73647295885894, + 51.085564225393554 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 61, + "label_id": 60, + "occluded": false, + "outside": false, + "points": [ + 125.86783527366472, + 101.86367376801435 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 63, + "label_id": 62, + "occluded": false, + "outside": false, + "points": [ + 199.28775272671066, + 114.13029555429613 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", "z_order": 0 } ], - "source": "manual" - } - ], - "version": 0 - }, - "22": { - "shapes": [ + "frame": 6, + "group": 0, + "id": 59, + "label_id": 58, + "occluded": false, + "outside": false, + "points": [], + "rotation": 0.0, + "source": "manual", + "type": "skeleton", + "z_order": 0 + }, + { + "attributes": [], + "elements": [], + "frame": 6, + "group": 0, + "id": 58, + "label_id": 57, + "occluded": false, + "outside": false, + "points": [ + 42.63157931421483, + 51.228199155397306, + 106.13274329786509, + 138.0929989443539 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [ + { + "attributes": [], + "elements": [ + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 7, + "label_id": 59, + "shapes": [ + { + "attributes": [], + "frame": 0, + "id": 11, + "occluded": false, + "outside": true, + "points": [ + 230.39103314621025, + 149.98846070356873 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 3, + "id": 12, + "occluded": false, + "outside": false, + "points": [ + 230.39103314621025, + 149.98846070356873 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "id": 12, + "occluded": false, + "outside": true, + "points": [ + 230.39103314621025, + 149.98846070356873 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 8, + "label_id": 60, + "shapes": [ + { + "attributes": [], + "frame": 0, + "id": 13, + "occluded": false, + "outside": false, + "points": [ + 292.80597636674844, + 284.1818841927473 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "id": 13, + "occluded": false, + "outside": true, + "points": [ + 292.80597636674844, + 284.1818841927473 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 9, + "label_id": 61, + "shapes": [ + { + "attributes": [], + "frame": 0, + "id": 14, + "occluded": false, + "outside": false, + "points": [ + 377.016603158851, + 94.95407858346152 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "id": 14, + "occluded": false, + "outside": true, + "points": [ + 377.016603158851, + 94.95407858346152 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 10, + "label_id": 62, + "shapes": [ + { + "attributes": [], + "frame": 0, + "id": 15, + "occluded": false, + "outside": false, + "points": [ + 499.86507710826913, + 316.59939612801213 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "id": 15, + "occluded": false, + "outside": true, + "points": [ + 499.86507710826913, + 316.59939612801213 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + } + ], + "frame": 0, + "group": 0, + "id": 6, + "label_id": 58, + "shapes": [ + { + "attributes": [], + "frame": 0, + "id": 10, + "occluded": false, + "outside": false, + "points": [], + "rotation": 0.0, + "type": "skeleton", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "id": 10, + "occluded": false, + "outside": true, + "points": [], + "rotation": 0.0, + "type": "skeleton", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "elements": [ + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 12, + "label_id": 59, + "shapes": [ + { + "attributes": [], + "frame": 6, + "id": 17, + "occluded": false, + "outside": false, + "points": [ + 92.95325643333308, + 129.2954675940839 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 13, + "label_id": 60, + "shapes": [ + { + "attributes": [], + "frame": 6, + "id": 18, + "occluded": false, + "outside": false, + "points": [ + 133.81649280769233, + 195.4883603907146 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 14, + "label_id": 61, + "shapes": [ + { + "attributes": [], + "frame": 6, + "id": 19, + "occluded": false, + "outside": false, + "points": [ + 188.94942364574058, + 102.14894385926891 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 15, + "label_id": 62, + "shapes": [ + { + "attributes": [], + "frame": 6, + "id": 20, + "occluded": false, + "outside": false, + "points": [ + 269.3786601426267, + 211.47877807640333 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + } + ], + "frame": 6, + "group": 0, + "id": 11, + "label_id": 58, + "shapes": [ + { + "attributes": [], + "frame": 6, + "id": 16, + "occluded": false, + "outside": false, + "points": [], + "rotation": 0.0, + "type": "skeleton", + "z_order": 0 + } + ], + "source": "manual" + } + ], + "version": 0 + }, + "22": { + "shapes": [ + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 3, + "id": 64, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 439.001953125, + 238.072265625, + 442.27361979103625, + 279.29221193910234 + ], + "rotation": 0.0, + "source": "manual", + "type": "polyline", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yz" + }, + { + "spec_id": 14, + "value": "2" + } + ], + "elements": [], + "frame": 0, + "group": 1, + "id": 65, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 109.6181640625, + 93.6806640625, + 150.7451171875, + 154.708984375 + ], + "rotation": 118.39999999999998, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 3, + "id": 66, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 414.140625, + 256.392578125, + 467.1372624053729, + 255.08366924483562 + ], + "rotation": 0.0, + "source": "manual", + "type": "polyline", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 4, + "id": 67, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 210.00390625, + 274.0576171875, + 240.10078833736043, + 258.3547764811883, + 267.10000000000036, + 266.40000000000146, + 278.7035955631618, + 261.62685883520135, + 281.32071996591367, + 253.77548562694574 + ], + "rotation": 0.0, + "source": "manual", + "type": "polyline", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 5, + "id": 68, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 227.015625, + 87.587890625, + 225.052845018663, + 153.01643273565423, + 283.90000000000146, + 158.20000000000073, + 251.90000000000146, + 121.0 + ], + "rotation": 0.0, + "source": "manual", + "type": "polygon", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 2, + "id": 69, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 37.0, + 25.0, + 49.0, + 32.0, + 23.0, + 59.0, + 19.0, + 61.0, + 17.0, + 63.0, + 15.0, + 65.0, + 14.0, + 66.0, + 12.0, + 68.0, + 11.0, + 69.0, + 10.0, + 70.0, + 9.0, + 70.0, + 9.0, + 70.0, + 9.0, + 70.0, + 9.0, + 71.0, + 8.0, + 71.0, + 8.0, + 72.0, + 7.0, + 72.0, + 7.0, + 72.0, + 7.0, + 72.0, + 7.0, + 72.0, + 7.0, + 73.0, + 6.0, + 73.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 4.0, + 75.0, + 4.0, + 75.0, + 4.0, + 75.0, + 3.0, + 76.0, + 3.0, + 76.0, + 3.0, + 77.0, + 2.0, + 77.0, + 2.0, + 77.0, + 2.0, + 77.0, + 2.0, + 77.0, + 2.0, + 77.0, + 1.0, + 1184.0, + 1.0, + 77.0, + 3.0, + 76.0, + 4.0, + 75.0, + 4.0, + 75.0, + 4.0, + 74.0, + 5.0, + 73.0, + 7.0, + 71.0, + 9.0, + 67.0, + 13.0, + 65.0, + 15.0, + 9.0, + 1.0, + 53.0, + 29.0, + 16.0, + 4.0, + 29.0, + 31.0, + 11.0, + 16.0, + 20.0, + 12.0, + 458.0, + 87.0, + 536.0, + 158.0 + ], + "rotation": 0.0, + "source": "manual", + "type": "mask", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 6, + "id": 71, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 414.48681640625, + 261.001953125, + 467.4834538116229, + 259.6930442448356 + ], + "rotation": 0.0, + "source": "manual", + "type": "polyline", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 72, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 502.5, + 319.90000000000146, + 635.3000000000011, + 319.90000000000146, + 651.0, + 374.7000000000007, + 499.90000000000146, + 375.5 + ], + "rotation": 0.0, + "source": "manual", + "type": "polygon", + "z_order": 3 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 75, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 673.2000000000007, + 222.40000000000146, + 693.5177345278626, + 240.03542476937582, + 647.8000000000011, + 287.2000000000007, + 620.8925323514832, + 266.8609498975875 + ], + "rotation": 0.0, + "source": "manual", + "type": "polygon", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 76, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 310.763369496879, + 196.19874876639187, + 339.55173792715505, + 228.9128038007966 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 4 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 15, + "id": 77, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 213.966796875, + 46.7294921875, + 239.5, + 72.30000000000109 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 16, + "id": 78, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 147.900390625, + 45.4208984375, + 171.40000000000146, + 70.30000000000109 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 16, + "id": 79, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 179.9443359375, + 46.0751953125, + 206.0, + 72.80000000000109 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 16, + "id": 80, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 113.80000000000109, + 45.400000000001455, + 137.3818359375, + 69.0 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 12, + "id": 81, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 147.8466796875, + 17.6083984375, + 170.1457031250011, + 39.871289443968635 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, { "attributes": [ { @@ -6086,50 +7264,50 @@ ], "elements": [], "frame": 0, - "group": 3, - "id": 64, + "group": 7, + "id": 82, "label_id": 67, "occluded": false, "outside": false, "points": [ - 439.001953125, - 238.072265625, - 442.27361979103625, - 279.29221193910234 + 113.80000000000109, + 17.600000000000364, + 138.65410232543945, + 40.47109413146973 ], "rotation": 0.0, "source": "manual", - "type": "polyline", - "z_order": 0 + "type": "rectangle", + "z_order": 1 }, { "attributes": [ { "spec_id": 13, - "value": "yz" + "value": "yy" }, { "spec_id": 14, - "value": "2" + "value": "1" } ], "elements": [], "frame": 0, - "group": 1, - "id": 65, + "group": 7, + "id": 83, "label_id": 67, "occluded": false, "outside": false, "points": [ - 109.6181640625, - 93.6806640625, - 150.7451171875, - 154.708984375 + 179.908203125, + 18.216796875, + 203.40000000000146, + 41.80000000000109 ], - "rotation": 118.39999999999998, + "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 0 + "z_order": 1 }, { "attributes": [ @@ -6144,21 +7322,21 @@ ], "elements": [], "frame": 0, - "group": 3, - "id": 66, + "group": 11, + "id": 84, "label_id": 67, "occluded": false, "outside": false, "points": [ - 414.140625, - 256.392578125, - 467.1372624053729, - 255.08366924483562 + 603.9000000000015, + 13.654296875, + 632.6692943572998, + 42.400000000001455 ], "rotation": 0.0, "source": "manual", - "type": "polyline", - "z_order": 0 + "type": "rectangle", + "z_order": 1 }, { "attributes": [ @@ -6173,27 +7351,21 @@ ], "elements": [], "frame": 0, - "group": 4, - "id": 67, + "group": 11, + "id": 85, "label_id": 67, "occluded": false, "outside": false, "points": [ - 210.00390625, - 274.0576171875, - 240.10078833736043, - 258.3547764811883, - 267.10000000000036, - 266.40000000000146, - 278.7035955631618, - 261.62685883520135, - 281.32071996591367, - 253.77548562694574 + 641.2000000000007, + 13.0, + 670.6457023620605, + 42.5 ], "rotation": 0.0, "source": "manual", - "type": "polyline", - "z_order": 0 + "type": "rectangle", + "z_order": 1 }, { "attributes": [ @@ -6208,25 +7380,21 @@ ], "elements": [], "frame": 0, - "group": 5, - "id": 68, + "group": 11, + "id": 86, "label_id": 67, "occluded": false, "outside": false, "points": [ - 227.015625, - 87.587890625, - 225.052845018663, - 153.01643273565423, - 283.90000000000146, - 158.20000000000073, - 251.90000000000146, - 121.0 + 681.0859375, + 13.0, + 711.3000000000011, + 43.20000000000073 ], "rotation": 0.0, "source": "manual", - "type": "polygon", - "z_order": 0 + "type": "rectangle", + "z_order": 1 }, { "attributes": [ @@ -6241,144 +7409,52 @@ ], "elements": [], "frame": 0, - "group": 2, - "id": 69, + "group": 12, + "id": 87, "label_id": 67, "occluded": false, "outside": false, "points": [ - 37.0, - 25.0, - 49.0, - 32.0, - 23.0, - 59.0, - 19.0, - 61.0, - 17.0, - 63.0, - 15.0, - 65.0, - 14.0, - 66.0, - 12.0, - 68.0, - 11.0, - 69.0, - 10.0, - 70.0, - 9.0, - 70.0, - 9.0, - 70.0, - 9.0, - 70.0, - 9.0, - 71.0, - 8.0, - 71.0, - 8.0, - 72.0, - 7.0, - 72.0, - 7.0, - 72.0, - 7.0, - 72.0, - 7.0, - 72.0, - 7.0, - 73.0, - 6.0, - 73.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 4.0, - 75.0, - 4.0, - 75.0, - 4.0, - 75.0, - 3.0, - 76.0, - 3.0, - 76.0, - 3.0, - 77.0, - 2.0, - 77.0, - 2.0, - 77.0, - 2.0, - 77.0, - 2.0, - 77.0, - 2.0, - 77.0, - 1.0, - 1184.0, - 1.0, - 77.0, - 3.0, - 76.0, - 4.0, - 75.0, - 4.0, - 75.0, - 4.0, - 74.0, - 5.0, - 73.0, - 7.0, - 71.0, - 9.0, - 67.0, - 13.0, - 65.0, - 15.0, - 9.0, - 1.0, - 53.0, - 29.0, - 16.0, - 4.0, - 29.0, - 31.0, - 11.0, - 16.0, - 20.0, - 12.0, - 458.0, - 87.0, - 536.0, - 158.0 + 212.6220703125, + 18.9169921875, + 236.8000000000011, + 42.5 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 88, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 361.0, + 302.10000000000036, + 368.90000000000146, + 316.0, + 348.10000000000036, + 318.60000000000036 ], "rotation": 0.0, "source": "manual", - "type": "mask", - "z_order": 0 + "type": "points", + "z_order": 4 }, { "attributes": [ @@ -6393,21 +7469,19 @@ ], "elements": [], "frame": 0, - "group": 6, - "id": 71, + "group": 17, + "id": 90, "label_id": 67, "occluded": false, "outside": false, "points": [ - 414.48681640625, - 261.001953125, - 467.4834538116229, - 259.6930442448356 + 60.828125, + 302.1923828125 ], "rotation": 0.0, "source": "manual", - "type": "polyline", - "z_order": 0 + "type": "points", + "z_order": 4 }, { "attributes": [ @@ -6422,25 +7496,21 @@ ], "elements": [], "frame": 0, - "group": 0, - "id": 72, + "group": 17, + "id": 91, "label_id": 67, "occluded": false, "outside": false, "points": [ - 502.5, - 319.90000000000146, - 635.3000000000011, - 319.90000000000146, - 651.0, - 374.7000000000007, - 499.90000000000146, - 375.5 + 34.65674500649766, + 268.16966984208375, + 75.22217324915982, + 324.43784450126077 ], "rotation": 0.0, "source": "manual", - "type": "polygon", - "z_order": 3 + "type": "rectangle", + "z_order": 4 }, { "attributes": [ @@ -6456,24 +7526,20 @@ "elements": [], "frame": 0, "group": 0, - "id": 75, + "id": 93, "label_id": 67, "occluded": false, "outside": false, "points": [ - 673.2000000000007, - 222.40000000000146, - 693.5177345278626, - 240.03542476937582, - 647.8000000000011, - 287.2000000000007, - 620.8925323514832, - 266.8609498975875 + 138.0, + 339.5, + 155.0, + 338.8000000000011 ], "rotation": 0.0, "source": "manual", - "type": "polygon", - "z_order": 1 + "type": "points", + "z_order": 4 }, { "attributes": [ @@ -6489,19 +7555,23 @@ "elements": [], "frame": 0, "group": 0, - "id": 76, + "id": 94, "label_id": 67, "occluded": false, "outside": false, "points": [ - 310.763369496879, - 196.19874876639187, - 339.55173792715505, - 228.9128038007966 + 43.162109375, + 178.533203125, + 59.51942683264497, + 190.96449996088631, + 71.29648664503111, + 177.22459684643582, + 100.08485507530895, + 167.41038033611403 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polyline", "z_order": 4 }, { @@ -6517,21 +7587,27 @@ ], "elements": [], "frame": 0, - "group": 15, - "id": 77, + "group": 0, + "id": 95, "label_id": 67, "occluded": false, "outside": false, "points": [ - 213.966796875, - 46.7294921875, - 239.5, - 72.30000000000109 + 101.3935546875, + 200.7783203125, + 135.41603451246556, + 214.5186195856586, + 117.75044479388816, + 248.54123682144018, + 85.03638975948161, + 235.4556148076772, + 73.25932994709547, + 204.0501219746493 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", - "z_order": 1 + "type": "polygon", + "z_order": 4 }, { "attributes": [ @@ -6546,789 +7622,790 @@ ], "elements": [], "frame": 0, - "group": 16, - "id": 78, - "label_id": 67, + "group": 0, + "id": 96, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 216.5673828125, + 208.00537109375, + 252.552734375, + 191.6484375 + ], + "rotation": 0.0, + "source": "manual", + "type": "ellipse", + "z_order": 4 + }, + { + "attributes": [], + "elements": [ + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 149, + "label_id": 71, + "occluded": true, + "outside": false, + "points": [ + 699.3046875, + 102.618369002279 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 148, + "label_id": 70, + "occluded": false, + "outside": false, + "points": [ + 738.9471427604549, + 102.15625 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 147, + "label_id": 69, + "occluded": false, + "outside": true, + "points": [ + 698.337805670104, + 67.95976734549367 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 150, + "label_id": 72, + "occluded": false, + "outside": true, + "points": [ + 700.7550293506938, + 134.04215851499248 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + } + ], + "frame": 0, + "group": 0, + "id": 142, + "label_id": 68, + "occluded": false, + "outside": false, + "points": [], + "rotation": 0.0, + "source": "manual", + "type": "skeleton", + "z_order": 4 + }, + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 89, + "label_id": 66, + "occluded": false, + "outside": false, + "points": [ + 225.70703125, + 314.6240234375 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 73, + "label_id": 66, + "occluded": false, + "outside": false, + "points": [ + 532.6000000000004, + 300.2000000000007, + 533.2000000000007, + 391.8000000000011, + 678.4693480835958, + 393.13736007351145, + 639.866763142998, + 300.8837248764885 + ], + "rotation": 0.0, + "source": "manual", + "type": "polygon", + "z_order": 4 + }, + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 74, + "label_id": 66, "occluded": false, "outside": false, "points": [ - 147.900390625, - 45.4208984375, - 171.40000000000146, - 70.30000000000109 + 618.228515625, + 215.12753906250146, + 688.3285156250004, + 284.5275390625011, + 690.2463290244214, + 278.63711201903425, + 626.1000000000004, + 206.70000000000073 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", - "z_order": 1 + "type": "polygon", + "z_order": 3 }, { - "attributes": [ + "attributes": [], + "elements": [ { - "spec_id": 13, - "value": "yy" + "attributes": [], + "frame": 0, + "group": 0, + "id": 146, + "label_id": 72, + "occluded": false, + "outside": true, + "points": [ + 615.6438723666288, + 128.1533203125 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 }, { - "spec_id": 14, - "value": "1" + "attributes": [], + "frame": 0, + "group": 0, + "id": 143, + "label_id": 69, + "occluded": false, + "outside": false, + "points": [ + 616.3137942416288, + 65.9970703125 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 145, + "label_id": 71, + "occluded": false, + "outside": false, + "points": [ + 585.0322265625, + 97.32024233915763 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 144, + "label_id": 70, + "occluded": false, + "outside": false, + "points": [ + 616.9673812833025, + 96.87642507954297 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 } ], - "elements": [], "frame": 0, - "group": 16, - "id": 79, - "label_id": 67, + "group": 0, + "id": 141, + "label_id": 68, "occluded": false, "outside": false, - "points": [ - 179.9443359375, - 46.0751953125, - 206.0, - 72.80000000000109 - ], + "points": [], "rotation": 0.0, "source": "manual", - "type": "rectangle", - "z_order": 1 + "type": "skeleton", + "z_order": 4 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], "frame": 0, - "group": 16, - "id": 80, - "label_id": 67, + "group": 17, + "id": 92, + "label_id": 66, "occluded": false, "outside": false, "points": [ - 113.80000000000109, - 45.400000000001455, - 137.3818359375, - 69.0 + 50.359375, + 283.8720703125 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", - "z_order": 1 + "type": "points", + "z_order": 4 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], "frame": 0, - "group": 12, - "id": 81, - "label_id": 67, + "group": 0, + "id": 70, + "label_id": 66, "occluded": false, "outside": false, "points": [ - 147.8466796875, - 17.6083984375, - 170.1457031250011, - 39.871289443968635 + 459.5, + 81.90000000000146, + 545.8000000000011, + 155.80020141601562 ], "rotation": 0.0, "source": "manual", "type": "rectangle", "z_order": 1 - }, + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "23": { + "shapes": [ { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], "frame": 0, - "group": 7, - "id": 82, - "label_id": 67, + "group": 0, + "id": 151, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 113.80000000000109, - 17.600000000000364, - 138.65410232543945, - 40.47109413146973 + 44.898003339767456, + 63.52153968811035, + 426.46817803382874, + 271.5955777168274 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 7, - "id": 83, - "label_id": 67, + "frame": 1, + "group": 0, + "id": 152, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 179.908203125, - 18.216796875, - 203.40000000000146, - 41.80000000000109 + 43.79357561469078, + 28.97564932703972, + 136.88880816102028, + 164.59188774228096 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } + "attributes": [], + "elements": [], + "frame": 2, + "group": 0, + "id": 153, + "label_id": 73, + "occluded": false, + "outside": false, + "points": [ + 108.50460643768383, + 165.35334844589306, + 795.2833482742317, + 679.8631751060493 ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [], "elements": [], - "frame": 0, - "group": 11, - "id": 84, - "label_id": 67, + "frame": 3, + "group": 0, + "id": 154, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 603.9000000000015, - 13.654296875, - 632.6692943572998, - 42.400000000001455 + 40.11789474487341, + 128.13260302543677, + 104.97083768844641, + 182.6914280414585 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 11, - "id": 85, - "label_id": 67, + "frame": 4, + "group": 0, + "id": 155, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 641.2000000000007, - 13.0, - 670.6457023620605, - 42.5 + 19.106867015361786, + 71.99510070085489, + 200.8463572859764, + 196.5581221222874 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 11, - "id": 86, - "label_id": 67, + "frame": 5, + "group": 0, + "id": 156, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 681.0859375, - 13.0, - 711.3000000000011, - 43.20000000000073 + 55.147965204716456, + 27.993821200729144, + 354.91261003613545, + 129.34078386128022 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 12, - "id": 87, - "label_id": 67, + "frame": 6, + "group": 0, + "id": 157, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 212.6220703125, - 18.9169921875, - 236.8000000000011, - 42.5 + 55.67655109167208, + 27.202181529999507, + 314.5855129838001, + 333.9054008126259 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, + "frame": 7, "group": 0, - "id": 88, - "label_id": 67, + "id": 158, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 361.0, - 302.10000000000036, - 368.90000000000146, - 316.0, - 348.10000000000036, - 318.60000000000036 + 30.75535711050179, + 51.1681019723419, + 245.49246947169377, + 374.42159963250197 ], "rotation": 0.0, "source": "manual", - "type": "points", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 17, - "id": 90, - "label_id": 67, + "frame": 8, + "group": 0, + "id": 159, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 60.828125, - 302.1923828125 + 78.72917294502258, + 28.6186763048172, + 456.07723474502563, + 214.25403320789337 ], "rotation": 0.0, "source": "manual", - "type": "points", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 17, - "id": 91, - "label_id": 67, + "frame": 9, + "group": 0, + "id": 160, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 34.65674500649766, - 268.16966984208375, - 75.22217324915982, - 324.43784450126077 + 40.30229317843805, + 20.90870136916601, + 277.9420801371325, + 141.0943407505747 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 4 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, + "frame": 10, "group": 0, - "id": 93, - "label_id": 67, + "id": 161, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 138.0, - 339.5, - 155.0, - 338.8000000000011 + 37.5426108896736, + 44.316280591488976, + 109.0776273667816, + 115.21824382543673 ], "rotation": 0.0, "source": "manual", - "type": "points", - "z_order": 4 - }, - { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "24": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "25": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "26": { + "shapes": [ + { + "attributes": [ { - "spec_id": 14, - "value": "1" + "spec_id": 15, + "value": "gt frame1 n1" } ], "elements": [], - "frame": 0, + "frame": 23, "group": 0, - "id": 94, - "label_id": 67, + "id": 169, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 43.162109375, - 178.533203125, - 59.51942683264497, - 190.96449996088631, - 71.29648664503111, - 177.22459684643582, - 100.08485507530895, - 167.41038033611403 + 17.650000000003274, + 11.30000000000291, + 30.55000000000291, + 21.700000000002547 ], "rotation": 0.0, - "source": "manual", - "type": "polyline", - "z_order": 4 + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 }, { "attributes": [ { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" + "spec_id": 15, + "value": "gt frame2 n2" } ], "elements": [], - "frame": 0, + "frame": 24, "group": 0, - "id": 95, - "label_id": 67, + "id": 170, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 101.3935546875, - 200.7783203125, - 135.41603451246556, - 214.5186195856586, - 117.75044479388816, - 248.54123682144018, - 85.03638975948161, - 235.4556148076772, - 73.25932994709547, - 204.0501219746493 + 18.850000000002183, + 12.000000000001819, + 25.850000000002183, + 19.50000000000182 ], "rotation": 0.0, - "source": "manual", - "type": "polygon", - "z_order": 4 + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 }, { "attributes": [ { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" + "spec_id": 15, + "value": "gt frame3 n3" } ], "elements": [], - "frame": 0, + "frame": 24, "group": 0, - "id": 96, - "label_id": 67, + "id": 171, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 216.5673828125, - 208.00537109375, - 252.552734375, - 191.6484375 + 26.150000000003274, + 25.00000000000182, + 34.150000000003274, + 34.50000000000182 ], "rotation": 0.0, - "source": "manual", - "type": "ellipse", - "z_order": 4 + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 }, { - "attributes": [], - "elements": [ - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 149, - "label_id": 71, - "occluded": true, - "outside": false, - "points": [ - 699.3046875, - 102.618369002279 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 148, - "label_id": 70, - "occluded": false, - "outside": false, - "points": [ - 738.9471427604549, - 102.15625 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 147, - "label_id": 69, - "occluded": false, - "outside": true, - "points": [ - 698.337805670104, - 67.95976734549367 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 150, - "label_id": 72, - "occluded": false, - "outside": true, - "points": [ - 700.7550293506938, - 134.04215851499248 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 + "spec_id": 15, + "value": "gt frame3 n1" } ], - "frame": 0, - "group": 0, - "id": 142, - "label_id": 68, - "occluded": false, - "outside": false, - "points": [], - "rotation": 0.0, - "source": "manual", - "type": "skeleton", - "z_order": 4 - }, - { - "attributes": [], "elements": [], - "frame": 0, + "frame": 25, "group": 0, - "id": 89, - "label_id": 66, + "id": 172, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 225.70703125, - 314.6240234375 + 24.600000000002183, + 11.500000000001819, + 37.10000000000218, + 18.700000000002547 ], "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "gt frame5 n1" + } + ], "elements": [], - "frame": 0, + "frame": 27, "group": 0, - "id": 73, - "label_id": 66, + "id": 175, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 532.6000000000004, - 300.2000000000007, - 533.2000000000007, - 391.8000000000011, - 678.4693480835958, - 393.13736007351145, - 639.866763142998, - 300.8837248764885 + 17.863216443472993, + 36.43614886308387, + 41.266725327279346, + 42.765472201610464 ], "rotation": 0.0, - "source": "manual", - "type": "polygon", - "z_order": 4 + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "gt frame5 n2" + } + ], "elements": [], - "frame": 0, + "frame": 27, "group": 0, - "id": 74, - "label_id": 66, + "id": 176, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 618.228515625, - 215.12753906250146, - 688.3285156250004, - 284.5275390625011, - 690.2463290244214, - 278.63711201903425, - 626.1000000000004, - 206.70000000000073 + 34.349609375, + 52.806640625, + 27.086274131672326, + 63.1830161588623, + 40.229131337355284, + 67.44868033965395, + 48.87574792004307, + 59.03264019917333, + 45.53238950807099, + 53.3835173651496 ], "rotation": 0.0, - "source": "manual", + "source": "Ground truth", "type": "polygon", - "z_order": 3 + "z_order": 0 }, { - "attributes": [], - "elements": [ - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 146, - "label_id": 72, - "occluded": false, - "outside": true, - "points": [ - 615.6438723666288, - 128.1533203125 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 143, - "label_id": 69, - "occluded": false, - "outside": false, - "points": [ - 616.3137942416288, - 65.9970703125 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 145, - "label_id": 71, - "occluded": false, - "outside": false, - "points": [ - 585.0322265625, - 97.32024233915763 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 144, - "label_id": 70, - "occluded": false, - "outside": false, - "points": [ - 616.9673812833025, - 96.87642507954297 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 + "spec_id": 15, + "value": "j1 frame1 n1" } ], + "elements": [], "frame": 0, "group": 0, - "id": 141, - "label_id": 68, + "id": 177, + "label_id": 75, "occluded": false, "outside": false, - "points": [], + "points": [ + 19.650000000003274, + 13.100000000002183, + 31.850000000004002, + 18.900000000001455 + ], "rotation": 0.0, "source": "manual", - "type": "skeleton", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame2 n1" + } + ], "elements": [], - "frame": 0, - "group": 17, - "id": 92, - "label_id": 66, + "frame": 1, + "group": 0, + "id": 178, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 50.359375, - 283.8720703125 + 18.650000000003274, + 10.500000000001819, + 28.650000000003274, + 15.200000000002547 ], "rotation": 0.0, "source": "manual", - "type": "points", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame2 n2" + } + ], "elements": [], - "frame": 0, + "frame": 1, "group": 0, - "id": 70, - "label_id": 66, + "id": 179, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 459.5, - 81.90000000000146, - 545.8000000000011, - 155.80020141601562 + 18.850000000002183, + 19.50000000000182, + 27.05000000000291, + 24.900000000001455 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "23": { - "shapes": [ + "z_order": 0 + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame6 n1" + } + ], "elements": [], - "frame": 0, + "frame": 5, "group": 0, - "id": 151, - "label_id": 73, + "id": 180, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 44.898003339767456, - 63.52153968811035, - 426.46817803382874, - 271.5955777168274 + 26.25000000000182, + 16.50000000000182, + 40.95000000000255, + 23.900000000001455 ], "rotation": 0.0, "source": "manual", @@ -7336,19 +8413,24 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame1 n1" + } + ], "elements": [], - "frame": 1, + "frame": 8, "group": 0, - "id": 152, - "label_id": 73, + "id": 181, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 43.79357561469078, - 28.97564932703972, - 136.88880816102028, - 164.59188774228096 + 14.650000000003274, + 10.000000000001819, + 25.750000000003638, + 17.30000000000109 ], "rotation": 0.0, "source": "manual", @@ -7356,19 +8438,24 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame1 n2" + } + ], "elements": [], - "frame": 2, + "frame": 8, "group": 0, - "id": 153, - "label_id": 73, + "id": 182, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 108.50460643768383, - 165.35334844589306, - 795.2833482742317, - 679.8631751060493 + 30.350000000002183, + 18.700000000002547, + 43.05000000000291, + 26.400000000003274 ], "rotation": 0.0, "source": "manual", @@ -7376,19 +8463,24 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame2 n1" + } + ], "elements": [], - "frame": 3, + "frame": 9, "group": 0, - "id": 154, - "label_id": 73, + "id": 183, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 40.11789474487341, - 128.13260302543677, - 104.97083768844641, - 182.6914280414585 + 9.200000000002547, + 34.35000000000218, + 21.900000000003274, + 38.55000000000291 ], "rotation": 0.0, "source": "manual", @@ -7396,79 +8488,167 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame2 n2" + } + ], "elements": [], - "frame": 4, + "frame": 9, "group": 0, - "id": 155, - "label_id": 73, + "id": 184, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 19.106867015361786, - 71.99510070085489, - 200.8463572859764, - 196.5581221222874 + 40.900390625, + 29.0498046875, + 48.80000000000291, + 30.350000000002183, + 45.10000000000218, + 39.25000000000182, + 45.70000000000255, + 24.450000000002547 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "points", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame5 n1" + } + ], "elements": [], - "frame": 5, + "frame": 12, "group": 0, - "id": 156, - "label_id": 73, + "id": 185, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 55.147965204716456, - 27.993821200729144, - 354.91261003613545, - 129.34078386128022 + 16.791015625, + 32.8505859375, + 27.858705213058784, + 37.01258996859542, + 21.633141273523506, + 39.77950727505595 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "points", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame1 n1" + } + ], "elements": [], - "frame": 6, + "frame": 16, "group": 0, - "id": 157, - "label_id": 73, + "id": 186, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 55.67655109167208, - 27.202181529999507, - 314.5855129838001, - 333.9054008126259 + 29.0498046875, + 14.2998046875, + 30.350000000002183, + 22.00000000000182, + 20.650000000003274, + 21.600000000002183, + 20.650000000003274, + 11.30000000000291 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame2 n1" + } + ], "elements": [], - "frame": 7, + "frame": 17, "group": 0, - "id": 158, - "label_id": 73, + "id": 187, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 30.75535711050179, - 51.1681019723419, - 245.49246947169377, - 374.42159963250197 + 51.2001953125, + 10.900390625, + 56.60000000000218, + 15.700000000002547, + 48.400000000003274, + 20.400000000003274 + ], + "rotation": 0.0, + "source": "manual", + "type": "polygon", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame5 n1" + } + ], + "elements": [], + "frame": 20, + "group": 0, + "id": 188, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 37.2998046875, + 7.7001953125, + 42.400000000003274, + 11.900000000003274, + 35.80000000000291, + 17.200000000002547, + 28.400000000003274, + 8.80000000000291, + 37.400000000003274, + 12.100000000002183 + ], + "rotation": 0.0, + "source": "manual", + "type": "polygon", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame5 n2" + } + ], + "elements": [], + "frame": 20, + "group": 0, + "id": 189, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 17.600000000002183, + 14.900000000003274, + 27.200000000002547, + 21.600000000004002 ], "rotation": 0.0, "source": "manual", @@ -7476,19 +8656,24 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame6 n1" + } + ], "elements": [], - "frame": 8, + "frame": 21, "group": 0, - "id": 159, - "label_id": 73, + "id": 190, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 78.72917294502258, - 28.6186763048172, - 456.07723474502563, - 214.25403320789337 + 43.15465253950242, + 24.59525439814206, + 55.395253809205315, + 35.071444674014856 ], "rotation": 0.0, "source": "manual", @@ -7496,19 +8681,24 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame7 n1" + } + ], "elements": [], - "frame": 9, + "frame": 22, "group": 0, - "id": 160, - "label_id": 73, + "id": 191, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 40.30229317843805, - 20.90870136916601, - 277.9420801371325, - 141.0943407505747 + 38.50000000000182, + 9.600000000002183, + 51.80000000000109, + 17.100000000002183 ], "rotation": 0.0, "source": "manual", @@ -7516,19 +8706,24 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame7 n2" + } + ], "elements": [], - "frame": 10, + "frame": 22, "group": 0, - "id": 161, - "label_id": 73, + "id": 192, + "label_id": 75, "occluded": false, "outside": false, "points": [ - 37.5426108896736, - 44.316280591488976, - 109.0776273667816, - 115.21824382543673 + 52.10000000000218, + 17.30000000000291, + 59.400000000001455, + 21.500000000003638 ], "rotation": 0.0, "source": "manual", @@ -7539,18 +8734,6 @@ "tags": [], "tracks": [], "version": 0 - }, - "24": { - "shapes": [], - "tags": [], - "tracks": [], - "version": 0 - }, - "25": { - "shapes": [], - "tags": [], - "tracks": [], - "version": 0 } } } \ No newline at end of file diff --git a/tests/python/shared/assets/cvat_db/cvat_data.tar.bz2 b/tests/python/shared/assets/cvat_db/cvat_data.tar.bz2 index 715520a6d9afad5ea62edffc6e08c35f37ab2670..c0aa2089963c48f1dd9e5c9148fd80ebdcf86df6 100644 GIT binary patch literal 181752 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
>)v{SF)2r+SbC1EoB16xat) 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 literal 169947 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 Ih!1tZ9706NU&7B?3NJk z+TTlXo{u^+#yw_T1q0Zj=dz;-igti1oFm^?mls#OE`B(Vsh2Y>TU_*<%`?Je-kE7a z1zA5tP$${YTTQYlEs}Bg(r`B1n=W*s1&jPSztr(L-9XA@uXAqbrK~Fv?00h+q`V`+ z`K&9J$3R#A<8Jl2$(Y_jpX;KWjJ<+zeXqx^`YRSZtCqYy@{>w-_Z4%WD%4HRNR&tz z_@Nzm_2F@41El7tpBq*~LV9Jw2hj^?#*VdZ>B?rx)PdsWu6p3TF}%CvhbGrBnN zx=hiQ1#f?rx^j-@6ibI0NVaV8pOc^1Slo|hYhxPC3^cywA0_I~T}&&*tnFer+mxe? z`B#0gKTuC?R=Z+o0ds6p4z_+VXD`aWpF6VcK)dz68-YJm)zDJ@`T7%7H@`MI7Rq@( z@91k?e>MwK(8NWl6+Ww%VDyF_o5rj+s7E*CZlU19uF`;`5ofe%aaDqnqkm>#VW874w_ILu5YvU>jhkYW!IpYSSCw&z?3-6FWJ*POv)Ux`>4xxAzOtd7N_Bet zVX3_$=}L${X8?vqA}Ht%SG2sXBA6IQUi9)=s(XUY=+0#FC)z^M{cA{qNK~xk3p*s8 z1?L*osK_?aIS}~;l2DgQw-6hzCNTOQRlgSQ@_D zn{B`KsUYdgkB$o4N3UTM_FpqTsf^5L$Y$9uvp>ins=TLB9#n~g}Pcz5-yP;#Q=TQyiREvPcKc<2O~-R@Y?_2Wc1Gk{;wMD5oB4I zXxT7o&Eji?VVMPV8NY^?rRI-t*N^Cv>DVgna#V)BH5KJ`#YDjvJO&skU)d2%wGjsB zU+EEb;$~FisD_*FfdS;(b~4D6(GGne&%723T^nBr?|3kwo3Wux&m zGmVa^#K2D_-!ksVWXPuRCKZefvs|Ria0h7s^A-(6pRO(sbuu&g+grZY-cNV9+|a`k zU%tf4C=DXr)+>IR)w*;o#b$Aq>tGM0ed7KvFoGuGHk@K2M#E!Bn`8jf-T`LK8xk-nkYB%Q2FcLLk+x4hw93nvT@gMcw!PDDhvC=Wl1pv5A<5 zP#|{fl^%Mxv>h~^AC8YUdnfB+>G6Grn*C~QG1THB&SBTI^ZI6WYl zApbt^?_MD{J9Ofxgn6qL3eH}yzjj+-kLDyTCJye?u4^6|Lr4M}MZLyF_Hzwhgd(f1J zjbYJ$_m>|Jo}Y-6X8mgF5S`0d_<=pwdRXXuc5I)n`u$Yj;c_OXtVt4l`{|Q2`MBmh z%i={!&s9-J#}Rg)iE2yh>i|9LM#LSQ>wpt1J6lns!KpHeFY=L=v_LB3A}4m!T~Y~s zOh)S3_ova^$+C#mnIxmnepi5Z;sq7Mc+<)8w09- z>gj)BoeOH4C&ntY_4(ibYBV`~No9{Qp-XSRy}Rd)guZhTP6+dQS}A(?^9Y(~@A|As z=Fo~o;bH?6l>Lg|MrjqrDP?I`sxI~+ZZ%RQ`j;(!S&h(JjE+n#dSCNi^-l^&0dx7X z+fM7UDgO5(G~4~`0q60hftDhZK{*Ra(=#7`=H0RzxZ%JF`_mL-G<5$XG85RTnrb>5 z=J%uf{`Yqn7ZwQIZKA@*^KJ-7-k)o42}8uY2;x`*!3g{i(x!f2+CkgpCwL=|$_ykU za7L&{?Rh8U(lR9ariUdMYr+!UXX>oD z4`=L*i+_4lCZEnF7}}+Pz5i@C8~85)@CStD-dPCs2G|0>cLT#&`~Uo2`&{!{$Iqnl z`{7Q+;w1zmU)}FdIR%A4MOW&~hXSajNp@~)N!#~yg>zm4u!8AG{&2hUVq7$*5|%~l zXOimt>E*q}9hU$ueUe&E^rRe1e>ch2WsAtiMXUQwE#sgR6hv>M-@=gq=e@IU{u{ST z22>JHPjGr1MMwJae-6o%(|sP;EEm|?qk_=@V?fUB@*MZm?u*WV{O(%#UQlx6xq1 za8-!}?TqVRbh#uq{336dZJxa-E>e4}MBO4J+~}lxm44E>5u+zX4#OPpjdCREu38^v z%mV!!%f#7aJKbg(5^{}Bh=xT|M(W_beG$<=I={Su~^<8z->Jzo2}D4 zR8r(m@X`L1$m30*a@@bCCLdn!C|t7cl9s01=-cS?p%t`rqBl?LX@-7h=-cSn-ptUy z{I~YMbRk!odDjQb*YZZ4PR>KVWG>e}Zc6$C;{CK*ojSSk9xn7Iy|2`8f0!@!%`t*@^}N%S<9|vgZY!4+pgdnv42IO6)p383 zRin?2o#xjdq>c;-L4cH~dWRrP&DnEd=-KrL>nbH4;cc5EgZqOW=-$F19iIK7RIVNK zoQT~z$^ zovkbg4vw8QFFmxs8X0qwi9d_)rlV8#>E>w;hEulj?Yrm7nwkNC*q=H{y+GK-v_BACwe2OS>|djGA&M*sq!?iSY1_Yc-- z#_pB`xnk1FCT$R9Qfg<@REK>d5TiZY_17dtk01~4em=M5fB8q@<(Rk!=U+eyd_~zd zip5~RAb;==ir}c#@Dgxgpt|O_!N<;NB?H;>5au8x+ws>gXYYNfJE=J2^3!>aMp;HI zP_#4tB5p6mq0W9Z-_yK~f6$UaeJ|03PQ{?LFFstfvAATJhDfXYyDi{fRInj@+fv^= zTw8}AT=BVNgy4Bv^T^!*Zb(#EYu79JTdi@c8}Z@^2y1fVHCpMdG?8?EC(h|l>2&Ey z)QjDZLfYO_;A5u7a4PBiFX6!y8qP>!g902 zCqOK~6|(P2!w9t3>cZ@IKX`SS{}OmGg59M!M^Z)c=BmHH7P%AJQ~o-}Gu7$5TNS)Z zTW9c#``7>noao4`TWni>^M>)?+Q0Scbp-}vz~Cp3HuZV(-U$!}h@%-91d@%IhL-+e zgTngIRg8MXP6X<|VG;;tB@n=A-?LF5c<5o%srFG>(=$A|MAU{Klg=vX)2VakyMXxz zohGj{>hoJdPwpvj&oV#1cUG-GC~A%h`ahFDaiRz7J7kByj}2dOFOVwl^J_I!R{!~@S}pw=^Vz~? z!S}@6!{>pjc@Y#FIK3t6C$W%2`a;J<9i^mhJ{>i99o^7baFa_#BJW7_%V6c!**ci$ z_{Zf|7i6yZXVLpuv3jn| zA~fANxgd$I-87$)JW*QlPSETC!#N#{4Ydw~Mucd+kB@!0ZT`xz%h2(kxsug8G#GUP zB0!O06`N-=M<%YG{wOgZG3(P;Teut1iDzWop+jx9UJ!6b;?o2R6cbp;;?u_9E21^* z2nq$B@Ke&BAJB%>XD13YMq#njA<1Q78FQEok0qZs%Y@gzH7CU1Olveux8U7XQ5ex~ z|JyKaz7Bn$;a)T`?(fCF|GG|nH+o0wMn_gN6t39dtyhPkNn~)LYNUjl;CSo%!Y9X8 zgzBdYw-FWxmDMcEN3WBPMAySt^53N~|69-=(!;H&H{Ee=^7%n$NM!WRXSi7Q#Qbd0&crfui*c zuW}xId|H3><`PYR2-EntGVTtGmgLFVA6&mqxcdL<6OMgnORw!YF?^Re!Y3ys_Hh;c zpb$>}3`abZ`Q2dtRP_7o`Q7EBZ=x~Wjq49Sz?Cb!{}oSpgv2CJMp>ZiC-rNPKh@Ri zy0A>%6SBs)5xZyYu;&gdNq_E#<6PqGOOY~vf6k_s<|VHwL?8%Bk1VXq5cI#1XCLcS zn~j>k4KZ*zk^B8OP{!+McE4}coar<$d>T#v-$$e$%X^3i|B=(%oW1UKm&O0aZ&IGhQ3bw5!nVUB z>~}GdAlzhTy8~-zis2IjPpDWsfDrrZ!U@j(*YUHv2MhXPQ8%7w-+inNa{c}JuRA6X zjr3!iU>0y&ChL6iY8&>7T~Y2FV61!SB=r6r6vae30VIudu7tY2tcyQh+cO7TC^06q z@qM45!XqShxvsWr!*$$GvZDG$Z-Bss_K_0xqLVBrKJx}M3=NVDI1HQ_)O&r%QaY!j z#3*$P*zAAel}AMd?H@xWf< z$@oRP(`nVJVKmu=d-9+c9)Xw_{Y9P0MdQ&r0|~JGm7hbHgF@Z>;e^z`u3_t)Aal;J z_fjD9Uz!!G+C~3-r<27M8K1juJoxKOZZIit;3qdOum|7x_y4udm+xi{0KkyG&UmRx zMr(k(V@zapi7paCCIOggz|Hr}TcW1hJVbWeYCRa;CZB>ecg?H=<0Z)l!AuzLObzSfD){;6I)CWGS$8W^8Zz_gqnk@7K3d zjiLU&7r6pk2QN3?yidB&zW%s5Zu)mn=+Cy{U8yUflP|vyS>`W4QeI&W86as3Zn9qw zx%!;pS)i1t|E$9tY%H`;d+TSiF2y`MX?^uMP$qCF@YC0ky#axSC{O*;J5sj-0~#iV zWh--=CtJVOyp-JOyLlC;oZo!+xqI)`fV@6t!YG}0lyuR=H z`_^5#iN?m}^Mw-aJB61w{HLe0@>)$c14CwhrB+|&*T|n3SUzPiO}6{2ZSJ?TZjxTj zhKIMNJ>LAje{Hg5V@>$erz!)DmPtXgqlup`OjFB&A?6`_3E~s>g{lvsmFIqu!V<=FzRpA1YD6dAZ-={tQzr>;a@c>s7qi9-M|-B=s|~gbP(HGtij5Lrz6+4l2U=GN_;`L^Ics{6IPGl@~T6{+f_Q!LAh2Q1)nu5 zsy3`9vMSO%vbt`U1XbiH4Vw#i6eO6MWLE~8j~3)4Tdc0>jlwW^T{?r6serlliLyDr ziGjG)X;eK@OMXbIUTQ5OT8AZ#8?Pm`{@HxUO-uZC)x6b2f%P6e~J{?vqxm5CH}5Psm;l1VR`f%c=gl`G;Y|ZwY=@ z)+wgGXe&lbUxPLyo0An8El}t}MYj5lH=TVyzOmgex7X5jmlKHQI>{Yk?=?@DGW$2` z+fL&rZ`W7@ZWO+F^hkTVB}>{5TTaOIe!apwkbf(yE)G=Fa>wV@jkn@}u&B{oC@|3ccFYP>jTy9uu73^|a2gMg#)GwX@Ulpp0Oj5TLdfY~j)$W+}_ zeX>4_Edt0_o?&^Z2g~-nglP2De?e9s-s!Uh$ASG&Mo4UGBtK}$;;gvJCm=$+%?N1+LwebzjX{d7h4%sBK|^|Qemr#c)q%YxF>Wx>{!`|41jl3$@1=gHyp zIjit_=x;(j2xdAu)^wM>>>9R2GVOnkFe0@jJ@QHK7Z56 zm?Pi%!r)AuZA$hCJ+(jJ`8Vnm>il)Lob2H{9o1+x!P4T&WbPTf z&OD9ea7K*UC+pR|PVh)zQ>R*_(JMIfYPpjYo>drKMpE0lU&f~2y+dz@8;L6}#XD_X zK#b+bh-J&JSC+b2LzC>KfGj6p-no(PTyz2SvKn^pu5bw7ciV3JIq_GPO|oLeDe8xG zc!cO&P>PF(e8y`04M@~Sk^GZ&nJSOd~4<Q(6m1N*o_*&1N&@!)6CV6#Cp~ohx4BNs=YmFt*L!dO23{9NQBbe(Y5g%@ z*R#$9Sgrc-gePboaWQBfc-TsFou1`MJ;YM7tbJkAo~Il8r9*s`m%<&&4) z(u?)i3}DC?+P2YY>ru`s2?Y7$){Bz*+sjAP)!52Y~{_MorJYSHyS_Skp!@!*@@34L|=W7oS96suFeTN)2k@H%Q& zo&WnN@!of}<0~Z`J=amXJRC@~5Ma5}P_eoPF&_}munX{HwS~I8!eey^u&B4Jy6W5d1V6{3 z3)3Vv4qJ-0BLx5P*rpg@ai_ZYvBmu^jH}ul1|Aj@1k=t4SQ9k;gKxMMpQgmmwS?z; zreR~@(t=HWU;QQsb#Wqa^EZ4(fV9ha<$`5|zrJ2QaliP*lDSW)f|;fdqdGdO;K1bs zoS@5)&L{E$%&o=AsEb^SWD+O7LlvomPZ@qOU_P1xm{Z+^?B1B7M4}d*w0C5Ftm}m}HKo z+=aI>78;{?tW^(GFBJ;KdcF}#M0+UFbm>L9OeF?#*Xng6)BWrJc3Ul#BzHqS0cxFT zQYGTJ6tzfH925TZJ`><}V64jf0!efj539x!oaHckK7ByRP{FId$WcJ7zx#jtIB2O- z$z&puws6goS(@M`cZ&~kt;IQt!T+L_<|I~Uh^kp4HHg%~THjMg6}SuNRib>}x&TJx zJlI+PcM*?#c6jvZs*fWEZhy?adw39gv-+JK_l*|s){zD3Eq9ANcNi+52OR<5VAS?;8i+qS9}XxDpoiFrQ3IVr)S*CWp`?ZuP$U$PPGo|c zz#zm6s{|Kbggi)=AV3%Z1Fned;tKF!q+ZS;pAdibMF&Cf(E2uXRR?mEn6kfy*HjsJ zLK6PM))XCdU91H|>H^-DM1-B43NPG6BgMs3ON@kAL)G)NAM-*lEfhfT!A06`EPv)9 z`Fs{A(*z;s#81@-59oqp(CQCJ1ZTubnxN(J1w{T5p1BCBuLdX!rBV@-V@KzMyP~D_ z0tknbbw%KWQ~A+KFbv^OkOKJEKs@LGulopMK>hF&UqGdzf_ze|?8OF%AK5ZXKx2g! z=(3U`hBcJ{Yc1b^-z;($#lbwFYUkT9l~G4+}`7%}JS=h^(^IhNQNh8HZ`I+SYZcVM{XFDC|ce%14#y0tD&+S5*Q+ zP{l;1YoZZ+Z|danISJ*!1@0zttW78Qr63?yp8sx6C}7RrHOC0F2-H0!BA^o{h}9A(KwBk@sSlThZdrg z^mFWfPs`E$tQ5-+p$-VOCV+wv5MqfT>J?MAv9Q9ZK^K*_Yp4OMwy$Nv^S5y$0Ugc< z9}7WGHhhd|_!9yUHmsffPc)TI&_S z(L{F@t`Ui7_*5b;fGC!rMI;Vdt7HO0?*NMnKoi+kjIljTtd&O(3Wm8s&!q(PNCMuq zB9~MO1cIcs=|uA|CrnNN|vQ`t$LX;}QZf5uo)cRf0^WC$JK z`2QpTJc|G;d=*93)nuc~*URdQE!)x z)@gjwUp7?)&$grhSYQfsq7M(OyP}IGpj1lXz(!g)Du?ybZt%GXAoLIf6951t5iLO( ziYibWw)0o<;Ykz|t%Wiqygs;zY=DSZ5MZIK1Q;lj%B{W{*)d6of+^&gle0MHT)z@P zl44?FVq#v`z08_sRaEubRe7y7U;w28l!`Dxa` zg#{CeZzd3ghk&3FfADH zG=5)sMe^q!C<>Q3{_>Ch0zxHKG9bena?rz+o}&XG1-4WU z(Jbhn~Mx`uls9A(BZ0n}tKm)txj|d+a+%fT4`e_B2`(S2s8ge45GIsfaEV1Ih@)o| z`cyhVi8y@d0@y%9{6#0qpjCU-R6>|2aZv_+R9N{r86{RW8Xu;3P)~%0$qoPzv_pP9 z;vI4vq8K3%h(teUY=%TGhN(b(FY4Ox@R0L$1&&A5)8|g3@AN<-WbvI6y6|*7{}u7YdhXE>ir%IdHvmP zU8F^njfesPMh~thbc}ouvg)F*XCdt0d0e1C z+{$*aB?2Atuf6&H9cEYZls(&mi&!rt2oQk?LJ;NiVuq%Bu50>)5r01Ufv2z&mqD_D5KD=PF~k`g2qouv{FQ zkpMdtK%5!1 zjCF!p0YFs{HboFYNQfdYIYL$VYy>LuV* zNh&iWa*|QZagHkY)CN}@JP1TykckV`dO5@NqTKsdxB59ELYFyp@@@xPU@jzM86N0E1Iu$RyZOZVE z`J5)5r#QTk$Q*&l9FfToK?IUX@GXjQU-bXqNx^nrHAA2T2#gVPG$#8~JXXJ*Y)cfj z4B}v3-*NGZ{-etE^=pR&DFC(Ca3Dt;BB-_5O#}+`JRY+JQ;}LL#c-|_;=F&_XHP*i zV$W(RzyTN%NF-alOF`Q~Y(9*Q3;f|i-(st9y$X$Encl!89-;I>~ zx_JwSQ}&$fxHdVi$G&s-R6O5d^RSP^bRUDyhzG61ov^RgwIFPKh*BHhElY$#2Y>hU zx$eJX(tmuf)Cxo?<^Jda8a}TkL|F(^yR2Q(=We#jOzebn9WN6tk>cl{Qsox%N#_^S z-6fUI(Yl)BE9wn_yi(0H%$*F3t_JlWh=3zLCSV!ax@OB_e?Q&iy)+wk;=Nz2$pjM` zsG{>U<@nwY`Ky;t;qjg1pT4PUe*^Z4Ts$L8rE0l(u~q0t6pdiz5C z!PZADf3)D#SXlW^O^LBKI+K1nlh1u?ZaKy|-8U{=$jHc&NhFd^o8*4BzsvjINC4%{ z(;-~@V%K3WS8FCBPv?>|x1c~4-6NX^9|J0L$d41#^CmjmXd(W9kK~G- zpde4Eu&N@BltB>gc7hEZL9+rdm_o7$GYDA_$EXg}z34ci5)j9TX^{J3LsvI5pS54( zAnfNYjJmjm~Y{54?quB)n1HqM0000yf4(KYJA1S4>63AxGOa))UzC9W zfQ#dkhU~E#G&B$2ZHuE$V8<2piC^o~ZI>H17WYza01yRE88lLAd3CNi1*EGX6aXd607eXw0EGaU zpM`B^8Y^d-vstsaf^e^+02us8qv!V*KU2IOHga*q04Y!maNV?MRe(Sg0D}DUj@nyf z->#bsmZHULsiH0F0H@QdyU~OHLcy}mfD!dv?o}U6EN%R0C!Y+yZXTh7n7WXmZ@~b7 z7c;H-{D;+@%KId=PeCx&U&0?gc08 zqvn2O?e`tcw)rQ}@ykT7ieCG6;KYHibFs7EfIt96KXU|v1o5WcH8X@CDe-(Lo3nOq z&DkW9NhFds%y9`Bgua zP=*Lazyg+m0!}ni8Fdff048RpCVxN>sG^m)?HK>;kSck1{f8$`hkgs(A!WTGgF-Nb z5V1;VOdH@a7s67!3?+baVAb_`doN7O#iO-Y~o<5C&#ua{3g|%MNuU zf@=2(1MnbIY&2uXw4Tn800+uKR(dUuILXr*$`!GV$OP7iZqc?Kv^>FG2R;qmWkjSk z!1i%oPhfK&@`{|q001oHaN|0=eZ5xqdvA!R|$pkeQWNG#|^r3w%7nb0GQ1#iIsqDMrsS{K_o+h0dPSc1`XzL zbToKu2-(+$3)nYJo57&0tO;4Zm80Xwi!!n-kXbF`)B2o%?oX^RO$}$>kPbnR znq9#hlkMhX>Fo2hl#w3FV>l5p1;L_ie5frYQNp*=zVA@9V#D^Fo_5kh~Rqhn#qrmVSr1^{^V_aYWfhOs{ zAHWDd6qeL!d|6$V#VwK+0h8!RAd?#GnBK&k8GDd>%reB9RqA8P?rJN$IPix1^;Vsz z0E%qX)llxGey=F(B0qlF{RXMZi9lQ+9VxeHv?&HWd%y%83b~JkRWazJ>YxkmYjF4A zL6-y{+siZvcSuBpNPU77)FMKa*25Rn`nAnBN(NWc&i%BcHJ_Bft4RI+GaOP#Bv7~b zlK!#|-*`w1ULB8M0zz=}&)Y`n?$U4C$8hcwpqd)f0mlWwpR&GXd;o*$ULR?W(GTmQ zi$bo}&~d+P`j&syMaH+DXM{hb!}mDkC1_ygrKfI{inrOpk(sN{Wxlwm(WJ)I%G2uN zD{1?<_X$&-0R$<1*eiJuAWw?tCqC6*&oZX@%`rQj;#m3Nzp?p(7%P{{e2Xs0XLtKz zV$iU>8O#5^r3vgN=60WLG-scu?eIs+g{rt@Og5GTu_v$cd7tP z`+3{2_P->g>BA_NZb zS1nZ-9~5EvQwGr{ciavI-5y5L_LG8XuT7lvVXNv>tIUDdsB0(u_4f!95GUIHR{CgE zg&zRDpov$RL|;Y-r-C4?^j#=TcmKxrH+xU-EZa`UQ!cY)b#9#NHlDeV-~a+3c?N{u zfqBpFX|?_)d-Rpc;U)Mz9=l9McP5_StDdahsu$r@j%v*q^Zjupwa&~yfEz=u5;EV~ z8Y}}rMWQ2h{r~6qbOAa7^Xaas&uors z84yVTrVOi3zybg+<5Q@r+?88H_k0QEgkB#ycnAVqoi`)v@RlF*oV=VGNkdWf_HBry z^kbMI@qhpbDoi!f;+nQ_=NksknKs=<*GheZiyx$nPK11U)m+D^Z}gwsKCeK8Yq`rB z7%h7~m2_(%6V96J^#lF(``$D_5CA8r-B#hi~gtpk{3edl(jy3Di* ziAQ=cc*dR)w`Jg>w?*av07t+E0w_alQA8mKLJ))iQBK4l2mq2JvwjG4%OuMxDCIGj z*4DLSpe+8}WY_DU2mlhq(Vt*^iP!hk%!=g7N6Y)w{kP;{SU& zp%VJpk4Oh(&M!88{<$E#-cQpe>+M}K68{gg7b@SGjee&8-}2HZe;2P)jltlSjgR?N zP)^tsqzOp~fA8z$pJjy&ytxUQ=q<)YakA2L0tq1G7B<$;-6fHrk&~awx)J~bGl}A^&sC;Q zfB`20x3as6sX_`8EpqzGu%wN7$&a1ZwMT^UJ#6y4H6RHD=VBfye*8hJp1HbB$$&^8 zo8lL^RhlF^(T>*WQrGDn`Zehxb+y_RqE|VkKB1bMk=955b>2(L^*1qII8k{gj3f56o&Xyg;mA)z=L%yS>yQ2 zB8+|I;{)U_j*r*FjdUBX1=f;8XzHE5e4KR23QpWLGx7z0-EFl+yM-R z{S_>LfF$U(s-oQ|C4KEN*=tDvh>5MY=h;p`ww>|+6}+(=J>peiW3K2Guv`N|O*8n<8+??vqD@pMY)g06+khjejSQmcF5D^@CfX;NFr+ z1UOYncHE$@*BNYHmq8>xCg>G2@_%>uwL9#EQd)d9C-V`eev7U*!$c2H{HX-If(CK`rKx)aB)%G~_XbP_nHV72#>S;`5vcS#Yf z%wnV5Ao5gwYYx*swe1}J8xDn*pTXyTe?43Eox#d$K>%DwRaNr0J1xIEF>IUVYivdE z-HK}v<)TCm5FXPA0vFv_k9$k^d2N+1T_y6mY2-&^`xQVQw4WL3Y`{2@Bo1&m_Pui%*3h}oK8yRahw4k zyFb=Tc70uT|9iat*z88f^5Ys&2NVG$VAuxRtzgZ6r`Bj(Z_n;BT@J(M>Ml8ts62}v zz*&$G>Q_I9<#0&^8r?qvF4!;-!lG=0H=BNURre&ZkbN5Wn&!pTP;-xrH#a!nx4U>Y z#1DmhdKB{Cu0zQXeiR0*g~4{l>zSy*$C_V!N|@spWp6$D<{}u5gP@JLv83a*}4*nMYN9VgDh* zTtzbQHE3Si?6u9iwpqA*?>*fA>T1G|XnW!iga|?qhtBJ@AGyK)GwvUlq7Z=yLIeeD za{rF_!i~GGwS-zv0Fq~RAcKRGLCTu9X%Et12F#(!+w~a3AcP?T5QHJ#{J-x6;x)(S zHf0cm2tp7r8^XYWhsP_mKaYdXvzarHICkNx`YKtm&V^TdhZZ-=sDa`HK(K)TLKz@N z!ier>eLZ=zF#+L}sf0nT&~6Zq)cIGv@2wt%T0m1+L^Us{PfhvqhjUsyhEv3zd4*DG z)PQ<*jmJi3-DTX>tEjxUbY2?x9QzzB5IYAkK0px>;q+^x>;Bk}Ha_BjL@pzg4ouw4 zb6-3rd=*haSDO!a00gi=ARvqE$mf*4qe1DjSKE(w9SMxMhj+`}_CD9ZNC1{O4rgMM z=N$Kqa|;Y_t8(at6W}zq?PDdC#L5NRR+I^1m{C1|2ogGwK!_seA_u;r>uKB6Pbb8s zP8paM?2lVVl*ob(gY81q=2Cp52437scC&p7oc(0-lFeCnS=P#Xp=4H4oTD^SwVDMz zmCAVm>ETx~SH+I3)+}Wqt|bS!h+qD@oO)D$k8k|=9=2y`@#}U!&72nLJdA#3Gu!rhD<^V0k%-1i?}{QTIbHP}%v zRzP3YleBet-4{lJnzd{&fKI*IQ)SI|L+cphN{t}<{5^4XFq$d&WC)oQrmkHEN}2e9 zbGGp5*XQ^WEwTz6s^s~y1?BPt9nsp46TU6xC+akUEA<(uq#IJ>mp>>lL?PQ>xM*MNP(MPpaymQ1yix=J|C#!+Y}oDya);qM zHD!A;XTbnKfO)~^t{lY67-x0-MVRKie`CKIj-LD8>Rya}p7MS9+xO?N03ZO7Yc7C^ zW9c~hk^er30j{{#z2c7)4?7-L-m0@zyo7_sxx6|yCZ;QFdF+!OC+`eaRs#4Z$be%c zkPmfdI~f$gd-~hfMwPzSUvNK_w(v}u9))L;B6V9KLdK#%qjmjy*$3L|LWx)^L#Vz>!Ec*LcYMfjmw!V*L z%U{WCG0U^_V1t{kT4rOBw3U&AS;%5Bay&}?q*(zf=ZEgoq-P>KbBZx3RMiuXqzQ1u z2r}t=kJ11;M(HK}8$Zuob8@gi1DeDjAlMiZGLygF z3g!X!whMs%q|UX67WK?6w?158;(eoZpZcS)sbjzTr7+2H+6u4)f^I<|;(tI8OJWHO zyzaKyu%vdr{kzT9aet*$NlJ$lMUmH_nnXWu{cVeF5TCTwcnBbpI=U~4Zitdn_};h` zLIizZ>2WzNj@hXl{VRsBViKm-I4+s(jbxH+Pkx8r?2mnn7v07hULYK;v_DRWzvGC2i~&9@#(9z0y} zKl&1(Zx`KTn$&uDE{oh@JER~rL;}zHVi|@`CL~sQ zM&0X4j(&l4&6tGE6Td@ygsr~Qy8zK4d`DLshLrOoU zFTnDs_8g^xp*zROX8yk~QSkeKgP0WHf(;D-TaSjSh*hm3qI!5;ItuBYp=xF_EHX8(MUabqz01#dxz#srm0D-P+>vA0d z2d$XKV_&0qcm;Q_tBy+fIK$Ir{y2LjRUfkRq>xEYhZZP5@|oe`oKZmJ#a;W=IKuKT zl06S~MC<>Kb%vr!EtFUI{osOVAs9aBfL!5OyCEu*gTL^bFQ{Nye3P6C0DsgaEKCq&H8GO~-^!@6W&+nlpRnY7< z8eC>CtL4zG8eSG}Dey_|vl|<+LJw-3btUEDWV3*PF9@b?c^s(OF6y5uh7&DR^>?kw zz(4{WPaUT2slD%8!qIQmA;tPs$0B5U0IrW{?b{4^_RsX)J;Pk_nasY9g;Yqgt0?$e zj;qyHN$dbZi-n?3A_NwY;qY<;1=BUH^I_|>{vO?gyh1$JO%BfbUr7jS%9X)r-Py;6& zK|g-3g{HRIJt`IZKp+P@mS7+RHwhO&ip$xp$$65u?@qUQcFQyOYn5h)ZcDMOPi9q2 z-iQ?kGkO_0CefN*wBf^2!wN@7%!EbxC+|E*A@)CWRATd`KBkK1W<4Y~V9oa*yHgpO z5yF<5k&%6NSE%4o=!VY}B%CcN3rWmB4m47(py}d-LL**LU}RcdVMkh@b?}3hWBjP5 zq#9kQw>JJA@AvxfNdO&>wvB z!wxXy0R#|12p&9=NhF{_1Q0+FK?D#4l1U_zA|N6HK?D#?5J3b%1Q0mmjyU6vIOC2) zib$#w5t1-~D4ag0rPKC2q(BchY3;hPET=QQ>4tqd;wj}?Z}L3Ws^)v|EqbLYIhinV@^f~pMT@OMkFPwzhA7`DYLY9U5!TWq}1e7i2Kh~vgKKjmc zHN!wy*xWE2@5f8(jz7T8qhJnp{oo{UDZ_HF}U(PxU?A+o|hThJw zufE*#()(K%PwuAKtK~!2xata5Os=3?t(!^CM6=|>6n0IHa%bOo0jTC|Jzc(J;}(Ja z4|GSP`NnuM{mDifTu#v%mxeTgKrtYL85i|gN}$_F|IR!&kFZ{Dw|cxn_1D9Fve*y^ zzI^37RmINfyDJ=|COwIh7ki_KdIAysZz_qjq^hyw_gt%wS=-fU-C_yLAHr4lKrIjh zbU+0LtsW`krb7O;a(a$v#1}Wj>kj6X22Zy)Qw!aq!Ku~k{+_wsWLcR?__(7x08GRjd3BI{wjIexx$4~@}?BjBtiLc8lDyL&=MEz_Flz8e09H&-i&ODo^i6V>Ql@~ zA4Wm|1AG`Kfg>WCLZ`dL5OMu1)>`#%%hF^}9JK_EN)X7Q_z2CYL{Nm-UQYQ7Oi_Dgt&d=4-NZ5bbBiFNHQ%CQB zUS}%?>fEGi9No2p(Zl^7a`}o5e{KSIbyK4SGJN@1h)kT?PFPvjW4ieJx#$OE#k0G} z0002B4d%H1K$KO>YFRDo9mDqgU%n3qKit!}aYcXygc@j`QZz66{%%D4Yr^Br-xi75N&BwN>~)aa-$n*9#P^YCq*ZoXLy zA4wFDfN}sr8QBO|t7rfd69vNnfB`z0{)EfpZ5?~6HM|Tibw}0ZAbEOzRx$?5akN~+ zrzF~D)2*cCSvK=dc`%#duxn0*^f>CQWU%fbE>h^HDQ=H=q4zY%Z>k5qDUAj_)jO%+ zX-hM4yhyGvlE^?uBUmNVpKP+;uV?i4BIW*sH=?(WntbQ5^M@UR@8gp-;YJRY&YBOG z8-XZke11~MsCQ2^)1sfqujNlC>$pI3xp?f$mir5VXXWW4abTo0q+Ga%l>5IVG7dD~ z7M-HgsR6Z}ZlPAx8oKf96e>-BYpuKLQp!s#44cCl2;nxpAI5EjBKs(*#HWxL1QH1aggK~GV6jp`ccYX&Pp|nnpkn^1+x(ti=K{-aH2K=q z4_mIi@eiOzSLy*5ukveXb8y>aEL&M^-a0o2!;hx4Pl}0&JI?we@;a(! zi#-ndSVi;y^=q3!aV0!zclX;G=}xMvde%*Xv{I04Cx`<0W~sutTUMLT5r*J0g^$DO4?$2p0BW#wSw7h2pN4}k6A~T`zP3tAxIh`0xRzSr+k9K zRkwJo{7H1ytYQDWp0kjy=|<`Fi<#K@=`uxz&ZI=sHv6^S86NNx{-+ToJzcI(<~8~Z zEy1bsXay%eN{3k7i|%u!XrW}}$(tJZ#OFJr02lk#$}&{Mh2=b_ihD-H+-D1p20IcYL8wB!;YhZ^^t`0YOW z$B*JYf^1CaGmkhQ4ck2cFH!7AlF^x^mF==6&a`3gqa&j!n zsYk0Q847(F_x8iP>USMVJ&OM)}{e!$PJjoFv!}dri@`&MwtUN zwX6~asH&(JqOT=24AH421c7Nyq$Gv&QE4G4Dg>28%?bka7}%?XjZ>P`S%v8gEK#2f zTM#g-LKdQiOIB+J1~T!qjuOEF6EzhrRUwT@R@FrSHfjKF{N9%Hp<2}Gs4G9F2aA32 zy~$64!Cui-TCyY(3II>%p#UiYXT<9{7u;OxEq>eD zZ~o6}6u=;nO49CV{<7B_^=@3v;vRg-eNXz``qDc)xT{GjDmG#S0D^JI*<~Us9sZWE ztPGh=CuX*zm#5_dKtTclAP}WJxC8_Eq>EP9x$5a2{yG`??Q(-gM1a;M^=eQ`=drr3 zifgynGHW^fomoRtF~S}$XSVA;;F6xyc3z? z=qC?fuTF@aX)|=8#YuJIkM3?=EtNePSH<|pO()#o_Q=1tBS=9l`!d%et>ca#u3|gq zc(kX5)N~5>7%N?*Uw5~w^r~X|Ky)?>1;{$c`>AJg3CYTZT7`Qr~BXuarW0RjM{siENSo6uAE)*`3PrrnTTCFvmfx~;V7 zL{OWO36dwE3Ht1j_7sxpt-op@0bEmEmkxA&7mj&pIKK z&XBo7>0{f7_rZIq80oMu3OB$&B(htHd&Nu*Ahwo}AjxL%1u*nxj1N_7sQty#|F zWbJF~*rjP&axz_N++3cIrNBa=G<40Z1o{_d_U+gr54n2oV^6u; z){)3}FZ;Cu?sxOhI4@hCZ!fpg%a>_HcBDR5KsvAY53Pe6Iq>t*dg}oK%SiHH&BWm_e^LgYufod4tzM~3Y4CL2Tn^4@vvtMo zvx;>s?QCpEQ!oJ@tW&Jr-U?^wdpZ*@#rEb!(L-Cmow5=3P;y&+m{$7;)w*x|%omC1 zI%72hixqAYKl+wzopKaJ2H+J&8O$)Yw`es0By56dah5ns0_V1LA?a2JM@%I zCf-fu^h9E>|GgQk%GB=Zrs;`BXMOTfosmnNMd7tf^hs+!q;vSW?G5Baa(o9d!eVY$ zRjIGd5dtCu#wy;}5Le@m_XgMWiv3P1{V8qdAHSDxXK|$I`T`FtMSG}g^l`A#_}(n( z-g-I^UR6?&J3{XSGG`N|EJY#Na65D`I%s$KY5Wa;vdYr&*^4%}+2m+H8htDjXr0bt z$NGD{j>4;}Dk7qb*MC}i4R22GC@P7I;o>zH=>Vo~2&03{+%Wxgga>yfcdhw-jz22s zDwmk5@p5&uceT_!dU#*^h^Y2DrF^50&*Fcl5Hm#(ceVEg0>3fXsUTMGdQ9U5?L(?f zbcv(d3CmqvGcYNe`T#l2{7lJ-sdROn%Res7SZ9|-X#SkV`GOK#cX^PUz5 zf@knv$JI;Y*JOeQFGSn{1nCNjK@}=;x){I%D>vFk%0}=0xLWA8TDeTjU+KQ4Zw@15 zolBL&H)eJdAM?{A9uAM}Nd#27#i+KD+JPvF44aJ4Xb>Ipx#9=1#h~-98YIq*>x%Eh zqw!R+{QU|;vIq5yOlr~5bx40kDrPi>BUXUxxqNARL4tkB736+?i4-}OK+A*UDE>@R zounyy=xBtte-NUYmaiJxLDlkK{vb$(?5C&Rn^4FuT`-O?Ib!Cg7!hg{H+lWd=FXN3 z(9Pm5LozbX9*aqf8VC>>Hg~t{-fNQ%MhZJAJ^NBaVKTdusaY>KGZEJXeCNV(7>0Y+ zE%O{5f%%86SARZKr#$oTczOo=ct8LkJ%9lbFePL^G2J8@TLrDPqRH3P_I>YqVYT9| zh!G6RVE_RX9bWw&{uLH?(z8YUQ3QgO)S&04Re)@C`=DGfwwX!Dq~kv(jFbcb2X(}C;a*vSq0|Iz zdS<1^d6GtEiAj|f*Su9;R#V>FZARCB@1A4JtZ7XHdee3qNK*YdIS6D#qyNiQnoIl3 zZm|S_u-?6iNlZgnz@)Qz0i=6f01*I4Ay0b^vV6Uq=`3`#K?-hj&6GzPW+g)oj^;(> zCSzG|wo7BH&T*japq5p(`dAap)vpHw25lwQA3aYT_vdoPeIq<`5&x}o(iMfsX3cu- zx&#JTz(F2^a?wiDadsZ44u&71F6>bSg}xBBcw}9x7aVrKPG((z8b<5&|gb!#M8kRBjaz z0Fsv#uq}m&l3NHVdcu!m3M>|ewdzIdX(-ie-j-WflBseEIb~YKA}p<9_gd27dtO%W zMPlk+_hM+TXSB?~&dY7)E@ES5zq^?TXsWu)J?UFs_PW-!t!scH0oQn*cklZ| z1Uas80OuU=c#DgOC7ML?5(XMlC!c|KcViGP7YTpF#DpQNVp}gSK86-h(i+7_367?> zSb2K82Djt0Nxs%=2jm!b2C9-!d50Py*Y=Klzsy5o{z9$j!lsQ6HA?POqCF0pOVCN_ zP~fn4?G^Ve&tL#Wjny7~+ot>|l3ud2p}b}0H29<(!UXhEFrecMladT;x>Plv0ldQ{m05sN;%d<2Z`y0k;i>G~0GNT8)%xOz(0_&y?*!i&M6 z?qK_Tt~mRk0WBBg`gJ9o>b_8s7P!n-^=sVQ>;336 z&(zwO$=dIzu7BPtCxf5>K%bg6^KXVUn4OX83$<=;>v8JWDnG=Ratu;G(GjKp+#``XQ)CM`Z)fd)P^0H7=|k zDW%F^`A`5rTRb^7Bo}H;hW8BSbNDd8h#PIZa>0A01VFn(zpLHFYq=dgJ{o`PNu>9e zU==ba$H>(xgKpEIDN*t~-v4d^S36U10w5ho#}Fz}2l@MP#@@FC)xDSR53 z)?BR8mAol>Gg@mwq_rNFz3E7XEcdnA?$xx~DSO3g^}R2UfCawKTju${vEp<*T`k>l z5DPV{(r@s|{`&L|NTERxB8`G;df+p_P|${LM)XgPZ~CJl2!wS3Z0W1I5cu zpM8tX&U60hkrRmx#KU^48>*sO!RI|US$pibk}&-Lzr`gEKz;UzBlxLA0J+`n{dMN^ zU6U^rK|K(@F*XDq zl%&#x(vwgkB9kH$6B8rE%(OeSS!Sd}Eh%PeQYmM#Xj@vW)~q5ON&@=?9D`zw#@qlkgv zKt(34-UbWJwCDQ2t~ECarNe#R|MO#(2goP+7A}}ADp5{6) z-S=FdF`fETE*;T#Psqu#*E=i6&T3l&2rUE>(P%AO(aDyP=?>-T7TNXjf?X+s`(alF zHnjkPg;;Ft>cEJA(F9mt*{-OI$6u@RJ92N`^E)RA6=(7V7Y%ysGORKxPySux)ySu*FHj0Xff*^)~0xq7Z)7o>Jp1{3srkz@- zGLxo5KmgpxfToB7E_du_RLhq!mSL3wGGS1KGGGKoS@ld-Pe6!>4l>o15G|M*YLr@B zoR-vFD2NO6?Mr&q(OKI}q?(NOfWjKgnI?{|+6#=)&bq&~jZT9D3*l!q&bTyCTzeOw zmRaL}H<7Cif{Lb%^V{utt{3?^e_`&a>tGTd(ljw{6lX`XJv`;5+k9XP=katzj#$uR^y51$sPWVu^+Fz4(B8yYUd6w@Q{gJggkkF#SC~dRp2`MXPd) zcBrJoIo>v~4rJIYQG1-%KP?6|iwPu>NhFdf6ETG=i(;EG&*5Wg zPl`(>>rZw3dcY?T$qX8FfzpG?wX^3qDYLFrXhGb|N9bJ{7-%%vQD*rP#6{gB82?zqNXo*}g33+9} zv%1494(`mWXLnV{i;|`dqfVw~xLUd6iUt*0Cc!~%oMo_yW&)Y$RpCiw3sk6sh>aWW z8KsN@F?wT1LCY(y2s$=Ed5M%lOz2lF0Chl$zyHy(h1s&e&>Kx^W26&9Q4~=Cx&WXC zlm&Dkm}SzdQUiWE=;5gA;q176=& zoYNYWjKEe$)Y_t{u$dDI>#a}m@SImS)dENdkEuz_T==qi13l960tv(rA|Vs|UM)1s zCsA{`A{*~9AFnb^?(d~*#g{hg)>S&<=$YY6(Bq9_5h-4oVLee$^=tN0R9Zh^fxr#h zRU;+Rt#Pw>N=taU<_H1+Qb_KM4;LAQ48`nm4>?Yv3Z?d#esB%Ii0d_*5*1_egU!7! zb{ojCM#xrupQYQjo+&=9zI(9B?d3oQa3Cl^fFuBbii8LdAV6RWf&~vp++4Tv$b;^5 z{|aoqs}p6l*J@OKb#q{EMll9pC03ki$m5gCfhW1qvAokYU4Hl=5`IoCl^*Kn3D#wO zKaP*3{n5+zRBQr;Ds&&zbpBV=S-p=>3__{QtSdLNmwA$?Hv3X zbo|_C(2zvOjEFZwNb)xSV{JX=93_OqyrN1y8-<0@G%0FUty@IYtt@2Z^7TES2!rO^ zK*=)eLpt5Iur^&qwFF@OQ~Dd0CS%2_{NTA~v8K%O8-={9>ixf#^x zPh2c?r6n*1GZrwS=))SxuVD3qs0^F8=vQM?F_v&0099%`_!0Q z>)X$_7N#xbxm6m@YtmDF|N8h`_Z{faIxctX@ElL-+txtCb(eRf(PSbOU(ih&u8TEg zQeakPQQy~Xu84>n8Rb41<93EVe~9+|H_GZ~{ub!1%ksbK{)3Lb`3=q+4Th|>>IEsf zc3z!PJ-);K9`79h&^A}+{Sl>XWyx83ki`WRR9dR4@x9;bewKCFDV{IrgJz>bs;Y{h zDyppGNZE}qLb-3+-Sx1QSEvT{k1>@qWxaBi8JRN}z`W`VEaRg!qUbleNTO#!^Dxr6OS zc1Rimu*CI0^65NJRlMaRzK@k($K}r*9St$$$kyf4;-AgoYuxGXHV7;5NDHZ(jCRj1 z)Ro9-YGW~_TR=cHq&C{**p_(bye(D8^!2h=9A@b?wFFejNU(WJqXL^7j$^ zcs45KIUdgU{Mk?DKimdK+~8U^@A^quWAHPqpn#yls zI~TaS>$|z%&tA2;m0c|ZF_u<3dCc27#!U@&{RT3jjY&)4(17Pn{xthyAP6c^vBMj4 zIBV4vRWEYfysRy2m1%KNC<_+7?{>xoPD1r59F&Xu*lpm&y!E@d)q0kb)HzmJYab%h z&bx>r)D#k-N%hZX=bv=p3Jg*7Zy0IK|3grV@L*MJzkfdomcf;g_zs_^cxh>N9cWU-xs z5(sGY-s9Hf4B=5V8HCHKg

(WC<$)Fx~t)E4tO!!BIW%X?{B*-Qnb&d!HChIL&7 z6F(%iC@xz}`oPB;h)7}q1<0TxMbR;>M7oJ4omY%)LEC{u5Jba71-k6=z{H?f;p7aW zQ#}OQh~E~f4#TS_X$}V~#+vEeU(v4Wc13ks#o(zjZ=|K7ARvo6ND9mJax{U+lm0Z4 zLRLa_;1-?i6ew%)`JW%N>04b7d+$JoC@W`mCSM1oZut0b>UGQ!jJETnYX&Cg7YnAv z65ZgDPRLW+JwsSh(`FdRh!9n3*1zo7T~A|-NRVt=r!~j}?c2eA%~Bt?WM5h1rCk@k zP-Jyw!=ZSJiN9eYTXWjzK%#Zv7GFA?W#dj(y(3A0K?Ajzd6IYoDi-gP58UfGw5|(n zbboH#wWL3&V)wpjiVOk>0tc~n@vM|HEf}2+F)URZ;6~{aIT0F(hx;!E_v4aI8=uLk zRgh`-)Xhr-l1R*H0HSTy``0~@h>3ahzVD^#e%I_{!S23yPvrYQRn>hxUi?85lQGY4J6<|;I!Ew1e|?Hwo}Lae8Am54yk}`TY>kGtkZ}$BllvUr>)!qE zCzr!Q@~OK`0@_Gccz#8xuUrsIJs**1pWL?oe)a#J8j5>!ESUUInSdaPQ$6D~?Lg17 zL@1UXiofLlO-I>P=$(?eXritgeW9#+Xeq7mF)>Z(qS_rgkplBI4drQxhEp z>z))d7Q`*TeF9J%lBx@oa2QOk7`2sm-4K9Xl>m&dnn98KXM$zRUqbJm79_+)1cQo2 z3=~F0Ou_KsEW+LG&mxw>P=qerx*GkNp9sQg-gYDwItVB^mUd;;p5@sxj&-o-8JU^Q zx1VlpKGwJoySXqXpGtZ(78261s9>U;Br?yb-L!}p<>ynDY%n>rnAwPzVj|r9|2NmY zh|$@7Lo*N6@u!Ud*Y`1R7^JOgLn^C;n4#M{Dkq_Mz0!X*?x#FapInB3Q4Z#6wrZ81 z2oMMXz27E%qY*M6AOIR_DA^N)L;zFc^BdP0$Zh&E+tueMFt1qRM+*)xWK{@|83qa` zors8;mt}&5S*LP{h$3^mcNfxf-M;(L_`W}_JFgG$d9MS2@IIzl|8L#w|1Wjee9%ES zKBPA6ZnzlTN-&4y^k0*&`ZxkthsF48jvgNzAr6Y!F}tO;w3uFwr!9lP}qs?x$I7w$aeU(Zo!d1F@D!=jc&1 z#YB~7Rw8UFAR=Y~mGtJA>Si#{OIv3cRMn#Ev%T%1#0kSKEZTW1XK|EOMO(XRQ%57K zIwq?+-B49RUuzUaLRg~U_#&Ebjs<}A&4!v;5s?vKQ&@qX#(E~^CZ?qWYzNUM1IN#S z&6f0Fa7YmhpF=(3`R8_S8$O;_?HjJIWm`15zQB`KbogoM`Us(;$y{B2AMJ!xSz@Sb zz8pf(DiSv&i`Zk{C4pCVXwB15WN?hsX^mF1OEUR9^6LJ4FGfbUw|xXOgWIJ3HeKW- z&aF0l8FXI5oP)rKfe-{VfWTlNAq5aY4l`YeGD#*CjIj(Y zY#+~5cmFv!}PuXpg`q2jClUt81jT_(-bFFMeXZ0nuJ%T*S$2aGGe1YUCOS;IOVMX#@Vm} z2%95K8bc8TMfDS-uG6y7qhm~s5UUk1m7y#Ex-;2L0I4BLjlWCX09x1lSb>Z5*Nkg= z7Eri>o^z{SjV4OGbM@X&U*vjE`THNC$atShJim9`eJKCK209B_nYt07q7cE09`@t{ zD-)Cw1IUzDD8GK!Z&^Oq9KU^7gvseheowiNNh{6N{rK@kt&0Oyp6G>>;m{(A|8MtG zel3VO(N8jQnjRYB@we;OlE5Gd=4U%{_M$TU%utj+-)rynb}~=b3i8YvpFw0_;@t+BblDotf2`&pj-Wy~{&H?3=sM?UUWSZ3{>mSC8i2nfX&4Zq740Op7AKmhZH z_QA&-RsCdiV_h$)Jr>ZAkbHWMb}(u3BY_a{LDae=9Kh@Y=bFwq65@lL zntiT1KvO}4wpSCw(c6K2$+acr?8>iW;)IFRwEQaXcMA7KPdoc<8@n?j7VD^tlMn5R zxz(J~&3p;k^R(<7W0|#f?Gd&*E{0xfd_@kmCN;9AHTTjaAcg^QSneL#5w){s{Wqg6nx$& z5Pmmz+UPo3MxxzbZBMtUkgS@L*XZcOL3NAAY}}0tj?LSZ!HO^afdEhwMKz)rA?vhj z&I2{n?yl-;BSPT`YO-tWDYC(mBvmr^5SVRdsx^J*28f6vVM_0o=P=gnn$2&mVQu~D z-$DL%!l!biu8Y!fXD>o_8o~z{yU$R8)qU^J&HZk&y^i03;eI~%)cD^ozTCG{pLz1O z{2~~m;eyZ3(;`NVD_!sN%e&7uN5vn?Ot->woSD0wB&D^`TBX@%IDfYAkW!#j;&~; z(f!oAi5|F{{8cYs_pTriN^d}8a@>8Ib|_ySKd*ftKt+NQq>@o9w&`cI()Yhg=gdPO z1OWxDAOVX~-ZZ*<6W`Q5Z``FWlh=3ZwEXzMRSHiMDqQBCF?UEPrq@Rhi|o^)1XGCs z2`A$EoBCbhDrkWMCQc-BG!ANS+PJrP`)OO?1OOU&!^DH?*6~UzWh=&%G-LA4&jC5o z*LB^Zs8bZT$?{7;5CGR}Wm-Wb8Fai|T#uX1q^HW@Hy!v%D@#Gt7&B81RKpFy1%!}EXu0!IaJwF4%D6(-kT zoV@$P$8+t1j}K0xMj}2jzCByd8LFp2G(k@RnP^7hM!>D_kqK~lZP&qi;%qXcaC4{N zRG4hDB!QTM*gAC5py=uI`tj#Grg7a-u=*Wgf!(%sKT)2C-muPKrfYE7m3Xin233~m za%zb#!Fc-xOH1ODFk&&9$=o#`TQA(K2LLMObx>J|j?XYxN>g3%x^nwY=;44=%Wd}; znXM~{q(7(|ay?>tzVI!jke841ybo~U_QoWnJAAL43}k7G!=5T8L-p5Ldws*p01MqV zJQ5c3m1Gh`F9!?K=Kw$*ZdU6fifDYVV*K@JK>(zrR>(SIH#Qr8Ulfyw9ahf)@{$dg5Iw ztNWcLE_F;h)&By8MSt%HpqQQp1Kni>!aH7_YD;S=^08Jc;!WVB=N=eL%}H}5@DYhx z{3L!MSG*@p@pduQ4#Jf+WG`p05Ffkr-?r7vh zDk`$xpGd_?gMjr_?OFZ2zp7Pt*`z~d6MrvBi+jPtAVO1O=ik7j$#Ya9Y++`1X?xsX zX8H*r!86+{)8fb**uDuATT7&Iv{CH)nixJd9*;kRzs%=sy;6@(mK3}gOhm#|?-8eO zVarBIB=;k>NW@a7zRre5Ir|aQ((-N;YB~nouml7UK?2^;^o>90@e<%p=KtIlyhC)a zyQh2iuu8@6)RT|=wpenV9^hMBDg6ll5PU0N+2!K$?z=y&HGENTGyp*%<M}{bLoaa}$(PTjx)OHXeL4SqCcOY?hvqkNkJdt?cizZFD~iwO!SU zq5Q40eeISW{*q85nZYe$#%Q(~`mwR_iP^s^Wm;87+LQzVcL)OP_1fM=yFuC0_LxgF z?a(413#o&TwW-BwiODp6{tLti0ORu6EHPYaT^Y8&sC0Pwl=%7G535(V2Nb%G)?az# zuMxX3`6KKg2mv>M-R@-Usiyzm$5|`LTlU;nvS`ml>OX^Fi-&4&Std~>Co^G@cAwo# z?eB9o((FwjDwd%f?jiNfdTx+~5i(o6-C1}3|H@#Nf9`_i=pF6?2qmo_GxhPt?igNG zB$IdDJ`MglPC4_a&6L|ur&rqOm>xHka+b`zJ$6e92p}Mg_2bSXa4t8ifX3MarPq^N zXp^Y3==^+L(vODma^4vtL;r58eKR_5!IQM9D!6KAK1xs5E?K*@F!@$Irur+;DoM^} zvSCV3Ow5zjEz*X*WQ4UuVGD zUHK2~`m!t>3urXHM}z_(jXV$o;s79IJ?lgaAs~_W!0wwRboH6bErA4!na}s>$*@oH z>mvBDFRgwYCttKP?y0G`$?9JN+Zw`pkFOaKlV3G6f~>k*q(^J{;Z7^HZ>YIw@bnmz z`WSf0X$vqJWB40}-o9Kqbdf*=2xH$fRzlZl)Jm?jabtODo)Kz!!@4Ex{#`<*mzFIJ ztrL@n-!&LRR#;c4XYo3vhoNBg_b6T7oe=Y58DE%!EgJSEQ1ihX=$bTJu-QTPo4b=@ zbe$SlcH5sEyy)EMlMB1t!eNO*%oR(LS@CnV)Jl9Wul8@g@{gHOg=yv`2E=VkP}owD z=ddv3%)cQ+9Nh_#WXCe?nX^~eOq@%|^7Eik3#>g>XH`u?`;=(w#OAp&8&bAAtEFV} zjiTZ1D`iJ~yHX|GPU&T!0t4boxxCHttClDA-T9d#Bem+>A|`2KH*fe`V$wztVK^zs zO^>jqO1xCy@M$Y3CZvC!$aAb`U!?c_)6;`4mSSmUZHYaD=5OSpmxcX3elWu;>l)g& z4?mV&Qw@5|4c(cpw*a75({g6o=KtL0laiRvWdt9o9Kdnf(s5<>Y`vZmQ z6NzA%c7{9rLq??XKIh^2WHe+egEuHKK+>4jSER^-NF%kP&uJFgSh3n^b|&nO@mG~wf%NR0;CcEyw_Yc! zuX_)(U;qLH1Fd(@xA3--UeW>$xAVf~Bx2;Kif+M0xntuh1E@&czlMLyVtJXin=R8SZICv)S(3RbR>3Y$~{2%;UIW$;=sdap-C%J(?8 z8oiWWZGZq2){%tJ_F$zI02i5lAuSp>q^N-W@osHgteuKi^#ln-0G3n=y055-T2W+_ zTTdVSqwnOIss23n{%%SlO=09u-8~O>zFG1UI|+Ub7aJmFiHx#h3H5 zAopoyYtD0AH=Xc(XSejfbNIfm;P5^1(eF9wo8Da4hhNoL02&`x&;7Eh zi0XV|%FP2hWp#pj0W`lM0~IE%9RLU_i_98L_l-VwgfN^zL*1R{ad)R8>f?FY70my{>#H5)UuUN+=EwZyI`vubn$gz6=v<=F zM{;v{jgcS#FByEkxXmx2Qbl{0udz+uF>#?{sQ@G|5E}sp7(pNz$bbl}#pHkAi^suR zkja{vg5>2>Ngx_@IfM&=G)-V4TDE;Fp7%Lswb$#ld`#DQ8nq{d zA0>(3#?V^C)Tn>CT<`9OdDwrp$E>sxKknTJ;>LSb;#k8%)ANA?$CH(Zmgm8A-kB&Z z4DdXZ?I>NdxT*&$tis}|ZN%+zD^tO2>WqqnZ~5FaRJ@Qx&;;R?nPDunRo!Gb5CA|B zM>(boe^A_uEUi8!7Yr;?J6S!dC!Gs+GHhQz+Vu`OWHtV8=U%Rlgv2rB9f1)J0~UG# zB3nV4iC@=^OLXz&bFk(`wm{gh004#Zp_9%inx9v(vy_bFnC-c`nA4Z?|Z!-rCF-o(I=(ah};IZwva|r3wtW)8u+BK=j7RZ1A zZd$%6ZTQOCX#V{tI|Gz+H_lc{`b?Vaz%~+Oh`mV{VhRmU~%XxcVtW310gM^=z zjgzk6y|?qm*|&VR0AtpPKjwn#G=l#d;Qk!uA;FL!L_^J)v)*}Cn){Y0mKd>hM>8^V zIW9e<+Iv)0`W|20=gNsud3V4+p-{ZBZ)~T@=j;-^`tI7syQ$&?4yxSl=9>(}!~p;g zc9-~J$x25?^C8!5{g$tT>Q$g*bK_`%L&BdwCPs$Luwx*(9qwxBZTXkXzFD8oo6sd; z;NJy#v1>(tvgbcK-It_y*M3eV#M*1n3;kM%vgz0!d%R6nGKx#Dl9ot700KhamW4ql z->gAj7rbQJZL+yG9!jg)A6jjHaelL6m`Efm3alS>RKk3RoA&2t7W> zZQ9Cly(hQwm(qgqaHAn777@8&96xysX8aeKOswuG&kdcvv|bv~QZgUXlB(qEB*`X# zbxEM#x)vuaJM+fYN10CR3YI(00sslQncC{wNPwPQO3-x(fB<^p?d43Bl;>I?MvFFS z4P^fgX0Y;8{q81t;twN^myYJrD3T#>wAqz^BImKvo2C1Q$S0;laFFPu1Plkte-|@H zoOHiuEn2C1!SUWgYK|DCP-AI8AQTiEo8-KcG*v5AdpNv%vhwFV#mU+u6-f=&^A(M5 zd`e1xOVd(o<|E)5zp>x0{Lz!q;rk%w(Ij}|yTrb|cUjAH%yaC+0am3q`h7k)I2UnU zQrzFyw9v`^)v&3+_wZip5Xs#;ZJHE`3dcxRY1r5BuP(JFMmhFl+U*bL*xk!aA+K|3PwU!a-rxh_qG3OJQx~ckDzGAZD=~%h zc~m@0CPcs3JLT)kp4$S9H$;6LOnh>}5#Oy%)ae2InKqj_&X?A22wA?h>VPdGGAE9p zdOjoHGf&F8!VfK`hNp>xnUmh=pq}*ABAph4_6?i3RaysVL!HBmM7r)XRU& z;QJG=k^eCUYIinq7{BA86XV@lAgi=)&%SHHU~ zQ{9KY7xxs8Wv1c(=|Krc3)a%lN8yOEo{c#EK9ELkUnr9dA^-t+Xn>vNy3>*X0g~Rp z5G)h(o30#OVsD&vIj(S!I){5mO!c4XHAA6Y_p0*Db@p!Wg%oxw@OJ+Di@h|u;h$2+ zUkRlJi`Q$<+u7|DdT5yzS6;6|Z?pmcfCou~z2lXi`3QL(yqEQjHxSczM|k=CX(YNty0 zqrt~$Y^fKzxh{1Z)V-#_4W1}pP{B^dLrrKp5ihx=*^$VqPAIq8M>0sGh!?`qLwzGN$Ip(L$jCF>OATPu(WS`qDswduH^o ziVlns!kGXBLd7dDSXFl(jiVDr!A$KfM0?|s+Ip+%xuOWgW97djfuH-?=*&i;8{ zEpI=u=;CE{wXxn^fdCSi-4P=pV*}))`tez@9~?~DOYLq`&XOkgIF_`#UxwLl_XI?t z`Y`@d-{t83*S>@S5r3456WSqev3h7&v%8q~_50^iX2}G#T{lJO7Ro|43}5G^^Wa~S z7ovbw{sHtUj1a{P*-5T^_yB}2SynvVu1mH91~a&nmC%3ZxNwc4G1uXWIIjdiiq$v6 zYcjp{01)KCpN`#3eOGrXU?&3M?iJia*1(xd* zsbI=r>ZWN(CEYQ}V0$*jdrxe=NeG0+qjtRi)jEt>n z06@5dSH-W&$z^HMKX10yn$L{clXEQ%8){_@1KmCDyQvg|yl$deAtc^9g~z2P`40wW zrr4!Uh&!l)l1|$qEBImy1u!l`fUHyQ+(c(p(I_SRPs^B>i_v}t7)q1we9ZGa3GpBT z3v-7OhYZfK_wlze;9NDEH!8LlY;8{Oeh5Du_J6U+;Ze|3id?jayIyq}^CdU5)zkGs z6Qvx-v@ZN~_2oow%|p84vw!NJg6FLyH^G==X9EFdi7+@~*X0$EaB2a)Z{| z%@YFAuZ+{XtTjxyi1x#+JQ|FcPt0!|k_P9qbh0d`B1w(I`PR-vD%R64$am*l*qU=f z>=96OBUuRJJ z9O|bC^>kNjWo+>mpj%{$lzVOGmX)ec8j~S1<8Zje9Z6201}7V8#GfBLVl_(VSEgjh zbc((p2p^)-H{qy9N0jea$W#8{07O{q^aT2Hi`x0G5L%_mPs>D1+C*%uk>XljYg6P_ z;o2Y{x1Lm#Qx3}=rphMIglnE#&%z=A0j(kc0nY6qy!9vEZd;EjnfBd$b@&hVx!S4K zsGhq$Nk6fRDtl6-eR<(8{l1|{&|~jYzuYeB5%&f4*tGTFf2W*pH?GX+<-T_EguHU# zx07LJ>ir}%{^;-fJx>8_qO+RQap&+jG>Be`Y}dN(m4_B7S`& zfB`Sc^;*nA_x{$&OEskS_+024yv!opJ-fF*{zY8kkAovqQ3Z<|Al>#TWQ)P~E--KA zUF5aDQK=m!!O264qu&&bUVlPL(rEyu)0^(xt7gL7;FAn#*h>ZkNN zxXN2>7q0L4@gj#cvIhC*>1g#$SQd{`P-0nd)zWFW#%EOU*EIi5M)jQC{I&Gt7)6+s z>ZMmAZR2*A@c=*y-Sil!JsisyzDY4%h4yI?J+{1vo zO?2JU~_krbFVurX?n#AVQsk}IQdEMgfQ>Lr2 z)?8eH*yr;065w z0t$hVqh~F6uix7Llool5{e2KJHf*)hcW2D_KT>mN=u>F)fD#LIW?maul;78FVy1Eu z)7>vxEgT!Y{h2BI&s&W7tWjxiTy|33&IeT?-;*7!5JWk6D2D7JkX1vQ@qKCmTZ;fI z9aLOR8#`px$VDe$sFZsrWq-v;1u=AUb(QSAPZC=x&pIuL2Rwy<0KerH8jNSFe+$iH zRncevD@liJo@d{Dj5_x)W5BGT8U)D_Z_a zN}fTH7h_-Mfbx(TQB1-&_&)C=HGpRb7o1LC-Wt&=e{)WuOP(9IoL|3Ysz3DNxO-`%Ruh&# z&u_K{HVnP9I(S>YZ!n{iCPN_O4%PP7rQvFLec~;dN>L8Bf>UuyFK)&SipF0dTl)eo z4Lz)WwtrfD4F@MTb#r=82)S6W1>Vs`%ZsU#?~Kqz`Zq;%_bJ9-5^~0px^ackq@n;b zVLp}4`?Bo=+5L*`nyt8&e{4J0N9)xjuNA8p2}1j_X#?hwLGh9+xe_#c=@J8HuY|&k z0zd`VeDJ$HsFQq59#8pa%MlVNZ<$CWqo;h(Uuk=#+{d!iQ=g}CITj-cGP$zRr z{cQ6-C`P1~2Nyn+l-nIP&t6E7=mY?pT{0GT4cEo!Y{kqvw72)4ImKqhh%$|r19Vf+ zNmK5U2ObT6@*%UB4u@_cYq99RwhK)R$_CtH`eyb0J0_}m(1;Kk?`zA&1L}K5{uM_x z{?*4`2QBhA|HkH;W#f3Xk+<#eVl5&u7e^%PD43hflHWwQD{Y}Xfv(RGGX-uh6(|*_ zk+>)#My{Cl-}3$^D+M0gIHXy7R)ik$Yx{hjd>kB04GMe;(wq)QHKSqJqr**E^^$P< zjjE^YS%82<9?$oAvXRngc+@>>*eB0MwI+KR@>028-67nU`M{^+#*208e*UD80GYHz z2@?{pJVp3UrG`f+om;Gw&&oG;biRfg1?|WnUd#3(we70lkWlesU!EqtJ3|=WhW}1& zP>s+FYFQd;H6}Z#!UzBW3`bbqMyT?w3+}e1#eUR(f*wr4vL6pFp511G{v_S+!(dXZ zWH{Q{p-(mrsBnHAz`*T|63&|$OKE54l~_r zLdG73e5!A zWUNF?M~YH(0YD_MU#Ki0WB0vB@Uvn;|c9GSEKs&l&`9MMVSVwCrI2R@? zXyPd`xs}S;w!xNdx^>rNS<^^Pm`-iGX|j61UWV1!6h6wC4+1i(ddD z7$AV-?7K{&&0B?d>b0ZN$#Hc(>-uYawtCCOs{|RJ-oFj#>M6+pKteUI%cFnDOMRxl z7DNxdqSU<~%c&=D;REtdH8?d;-1Er26KOA7{Lcj_gh*AWes`(2LvsqE%P)J`i<{g# zmdq{xLA=Xkdp>&RjgPGu527S6SS;&L;QZ!wbvyqDD7o9DumBK82u@4taz4M)(S0>f zZw)0?5@QjH@s&ZXb=S^LKAq3d{|c$L(ct`BumBnP?yoi-nm43LCd>S>uKWA+>RzgC z76qf-XG%yX-ng;w$l6D^SAqf-1FGvIH{rkE+u0dPfUEr&N@WXvfaYw2xgcX7k?n`M zXWhX!0R#YK?Wr#I(S66Oa}a4VeaAL!dW+3Fl{}9((yts*j_W7nlm6&q<68MO4^=ft z>OR(wx?}PP&oi;UBhu=YNFbeS#!CBD@2-irnPsS}-C!)l<`a+QZ?${?eS3@m4hjUn z>s{Eeu1+M0n$>hGk@(BL)jbcO^575vp#mnarov5JMf-$S`81o{DjjKYwKhL`yl5Z~j#7Rr0s!vn-*k}dke7O% zmYp?$1euJ3x>rX{`}C4d&hNQn_i`KlJPOk{Q7Iaam9_FQSDN8jmT}2L;T)vr9Sz~c3 z8I$tv0--DF7p+}*U;qdJ(_-D#uHLQuL1NOca9>qTY*YdEQu;h6>XN?MDWG#kfx$nYc0ssI9=b}<_ef)KNQ+Bg<53t+|=Q0`y ztPYzFfbG9Ord|C#XWylpuz*Rgax0>6BX;I=c&rl~`oFCt5Jnv7^Y(XeWr%nn&DDGV zo-iB8XEv)zT2!W=)bC)DoNKTDIpiyMV)kbRaQik1F9JdajDH=xvp2&`_`G5YdGmsinY3y3RVf-{OD-k-nW-1cOJ5(5m%=-Os%#WGr7HHBZ$LXYC-GTrlWND|r?|3BfA3$;6y8pkH-sX>}J@qtpui{ls{>vA|ui?55=k^7M z!+RI}j!0jj}hjx$mc)}?oeW5CHbd0>;^bP%QvL4TBZ(n#w1mKV)5+xf}o0YfQ z+oyC~n@?3l7t?R&^Y|qmSe%ma`L5&BZXVZh2R;a)OaOrtEABIMd)GwxGFuNc6la@f z9|(yuXWNsX%uS)4w7gPBWyFbhSYEJd9#E*DhjFi7|EQ3PX!D=l=cDf(VxPpAj?`_8 zKs0idA;?i#Ra6P8iJrm*bGGq!jXkp~rLE_4|JWqdZ8vf%D8B*NiXK+-`TXJd(1EeK zL}x(#dWeV-Pr;yYg{)=rwk@ar{ZStb|EBRU$cmWc+-^;wGahp}_gxAf)q+>k59i&m zc{ZDOTxl=D$J<#-XZ=7;k>TtoEz$32mu3m+dK#npE3UX!bkCoGDKlZ z+aLg1Qi8K9SWuYsE|snCTVh+Qal0Ck!8Lqq&P<&9#}(BlO37Pe(}GhWGp+P-pAT<$ zKE(toDuGw=aUg&$n{WGeP~qo|s(=I&4HVKzB%<@aNM`>kx|Gh|7ja4I_9RNQ{05G3 zqx&16=Kt=BrSlsw->uQI=)60VN-fOCZYWiIml(wxP17T~OjorcA^;1!@7^)!>43s2 zZ~`&n>VgX>1_OK7t+1?Alb`BCYFRNw+vERBD9?VW^}zUu<|yOjS?4JFxcp1G0_x`= zXE|ryAOM01v%DAEKF^*TeqN@z%8!nHvhqCbUB(pk-sIg)vI5iH{3nX*oYegP;cB$z z>^;4h@^xRuzqh&Ofd>ePJ+8;K&`w_jMR^=84*rvN+IY@UF9^1A9-eeLAo_s!ngBt;ZpHv97N)y!r?`s}~4~gkE3WYxw_u zcp>(}wSCzsa+w710R+iXaNYn!G#sqtU_qv+0e3#`?$4*#YBl;D7hxA3UUQjTmGyJk z?&q2H>*f<#ARrMq!7zW?AP4{x;=x?rjN*>g0)ws702;}G@kNR8CpZdSvh`0pS5orv z_OGshM){1l|DSc(IphlGq9SQ43MNA{a<3uI+t~WhQ6k}-8$JWB)l{p!KfCVZaw#By zaBH$Cb3e{k{?=uH5pKOcJX`l*2nq2;&B>V;VRf`{GI%K|`LUS&kS%8LfXz=yKZ?WL8m7RiK?KyLN|K?5a%`;XG7 z5ssp!0-{L(_`023{IT<)W&sxOF+`z2{@?(M%v1w&*aZkhPor3J=l}X!&BXl~?>3p( z_SHLqnCW~{G%p=me`BJzU4I$LF>Z%vEUeDF;V%RJ6zf1W6{iIIuPYc+^Jr{;0RT*X zdeKrcAZXW0IX>KQ*d!`ca&6wNsNP3)Gg1J8PdP`w>x4~_=P!D>NYiZ|K28f6(VNKv z3A@8*(%IL4p2WnMdASHa4v)}_wWsT=VU$P&eGrnAQrxO)RYu=4H7VnpiK9oKJt%DS zI#Os(N}8ZcS;yM|Km;)H_|FdA-g>7Mxu9#eS}ZOZ06@L_KB#cxw(Z(O6A~cA#>|Zj zN)%1aEf7Qyjio(EGc!+xA_KVrBur@EIMwb@wbhIFk^^&1>Le3u1|3yzm`*-=BlEcZ zDuA|wY7t!?@@JugN~G)l=@9fQA`i}*D|My!cBVp6N`CJLtKycdQ%ZYSiR1X8!&W8! z4YhymSme~tf0k!mub`lkY`^z>&1QP6kFJ0S;d?2Fwm<8ZT z1n@lQOI5N!tMRe}wY8A5ygK(AS1IE1 z^Z!+`fPx0~7K8#vD>a|$yQs(7WFss9Alb@teGEp2kF)UvO=Xh7Te|D19(T@-dQw-h zHDVa}_Ng+q+e##@{?v(>Qddn&08|p1BjHFj)2dp2UP(^Z?IYK#fPg26f*D!9<4?au zP1WF&!-18LLbzt1Z#4n}0F@;649}SRs}WLdX%8HYnQC<(#?)i(ZS-BfYnOKK_}$u| zUaOx)aNkDec6CRd_Nt2hZ!ws9$=HQBDRab6>8b z8@L}7y1)SqvKfzh;m*D0bONCFHB~?=pl9dmm0P#!cYp}EeI-|WDG#cVy5iS~&y;ca zf(|Q(kx#N?#JGUycmN)QO)s=JLYy$9KqB}+BF=z|ELDQH^6Rlb0d^=x)}&EH0A&$> z3$3O%+o91Yay2^~a*WDtZ?WwrX7^14_EqZgY81&Nk4uus0EL9aTmPnnWfGnweV?BH z)qN!Qz-{sAU0awAFkshXm2b(S#QzdN%{{x?%|zS?Ab}UTd(*daO{-b%=#|%EGV+qa z?F1MAi59Wno^pjd;C-5h{*8pqDMWVW0005NA}9cY(|sg8XJ)GyngAe~M9E>E%OYZ; zN|;K_reYBUX$ag?r{c=DfwYPKSYM53L!vWqH->|~VODgl`_d3}3P zP^|$rP=o-M?IfxJT`7|a0Bou9>6@L)_Hv~1CiDVxl%NEdC{;lxxN@vWalg!!CKa>6uh?e4l{u>-Ng#&|eEX%BGdFaTGBzMBv}>4Ib9lJ0 z03zPgF=tgh>&T+Ks_xR=Iq`nU3^)fuK@?_d4tilu>mqB++;rLrpm;7jolnjv8J<5$ zD%_-T7lDudI@dHaJvQ6DY=u*^Zz_j**RXQjI%(B##DXJwJbID=C4*Zk`i}s<$1DI1 z?MW#vA7s!#N!(Z8JM`U+J?Ks6E^Jpn#2cFm#uNM)f&hq#>vm=dg&~yA@!acAZy~(@ zmDGogUTF7(2=A|0iKE5k5&!|FbbbEZ>BrojznI7U$~ML`jZQDQH}|xrpUE@r0^uM4Bq*fuv(HIk zLsJR)8h*$(HsGwkPI$xe}1w`#Pn;dL@ zmH%a(%wY8RTyNQ{rtbt#iD5sTt+hln782|cIh{itACywGxS5Zy$6 zM{9HLtqAJ9h1p{}$^d}{Ud8wW27M;WL*&&UXfy9LXU1FkTc>U0Pdq1R{$PP-y>B&E z>)$v4L8P#AU7*EvR#%=$005n+t#-}%XtbW*T#rG=b+kkQ2bFwjjG&W0Tu1`z@YFBS zP)gZ!^Z7Me)ux~9U*>-N%r;Y)eP@I5o5G@9NdFPv8^lheKn+hILusw{HMS}o#Uo)) z>*cGtFOGhpBk6~{UW0M{Jf9zj!tr-^IA!lw`FwUXs%ZU;aN=bA%6MOw+j3F()#%)M zn|1Q@6>$>>5jfR-j)ch}T{at;6dA{}g}}eTU8FD!1qT}q2esdQ4qv{^YCsf)E-o%E za@@Bq#aou;xh`4{@TtZ}+yBnI_J1#J@O!R@&1d{-cON~6QQvg_(Riq1LV?FKm1#N3 zYd)tFRdadTO%9XjGxJAWk!Bt<8(m|hlk|#UiMr36j%;RI{YgQ*?v!=0D>|CDPAdhh zcqCTIf&c?!H4w{eiwU#;(sK`^SJ7L-NQXO$6uaXSkh>1dUkq<#G6frUdHX&>jV8mV z1iEUaHdOvgJ^~KJSx6V*Nu3iRsW`$_&`Ljxh{Is&nmBQ2Lf&j$aB9@N&c6WcKoh_D z`zDyGrrT+nA|e9p`;Eu~-aqK4$&%0e;|PHeTXlOj2#6!;S!E7j%w5Fij-8VViNiU_ zbW_sN_dd`L9NoHV--Ehxoc{k$gB#~pR^NN~suHKDc9?iR-HT;En%A?raSGhvY%3kI&nxr zl1KEhHT{%eD*zSEaDX0qm#e;d9mZ?TvVNizO2@ugISK%7m4HCb z9wMIPKo&xnR6*D%AxgLV_X_SCGgg)=du__DY56?AUf+a+IA?3iYHu~|eC%#J)bB5^ zb{J{0Duh6UT&0Tcpu|6I?9s34JF8Y|=~JNmSgSmlY=f^$wJnP0jO2p8ngvpNbo-&4 zU(b~KqjZ)HC6bXB%KIemRYsfZhZ?Z!99o?PVlVbZ43w!E67@Bnx+*7C9`Ef%?mAG% zP_17!-a|+VHX{i@U=AQDegNj1{PqQO?+GYxJt?;4^> zjf@}w2(wpHcOyjJu6L=c@TqlAQh2mVsl3Q@P^+AF?aA55Bc3nvwn~sCIxm~a#HHzG z&Y>AaDxWZsP=C~pq-=iHO){6%8fqg@QiZPm8d1nzb`#V)u{M!3tBx_~d^t;y|0wAX zOTT5RDA%h|LEYY4B!^7afCz{Xzm@1*M858zUvocJ8YREVKm~daM2o{uHsf*AG2Ue?E1_@Z6et)|xt%Z0B@aMJwsmr8EmDR?G- z?<~y4w2G&}Q1e9K&seC4F9|>d00JI|FX+oOKDvHVrhpK+*zA8-f$`EMMFKoqUBroV zyUcTKH+tdJXF(QY3;_aBvZMfkFufpl`$gFMvs@-E8(Pl;H={d+WRY=8+WM(Dy#~$H zqg}6p`!>J$mAq(I#nqEI>{VM|NdzKtS|=5szPv|(>>Q$XIatR0x`u=XwOoPTzZ6e) zf|7HVVkj>>MZn)(T*Evzo0YL~AVTu&UHbWw%W9df%1f^<(jvBs4hyRh(!);KH~&*T zuiDxTO`^F)-TKSu*>0sJT1kSpSzWtH&tV&t=5V9tbMw;WBhY7!?FXHE0<2PSFHHz`N0QZgDxQfSsBFoTpJ3mAe+G#rtFe=k4AU91w zkB6uGrOsDFL1MDV*9h_S$}+sQem3DFr|02LSLv-Nv*PxIYxz|h;z$58?!A6a7^L$8 z4niPgrBMzAqJwUTf&l;mpeYPWkn8eUm0m3%W!E7*6>ieV+0=iU3C+zVhjMXZ2ie!d z9!=rVJB^E5M@YiK)|*=Ni#t7!J6%Ob%WL^FBNw|^y~segEcZbeXQzngppt(Is$<%^ zviHVX^HVj31vY%St5=+MIct56uR$e}%X#rg5UD)3R%6Z-tl{hG;GBfXC(6eT530M$ zq??G;Ea-iFn3$YiF@@jlg`u3Q{c*Sr(R$7!%5q#_BoI5`0ifj9kw4|KV)^MGS>hT1 zKqi1is-22}6G3;rlusm--PJoR!R8WgWTKx}@#=3pGUM9kg--i%{RrU1ZQ?wE52Y9& z{J{q+VhB0)r1u)X1;dcVNN$Gn5I_(?1P}xeK?DFGhzSG`K;w=$ zPFoK{d2tp7T3w__@~XZZcs&3LrFeOT}LIds~TYQ-s?fzO?7oPeS=uTKQn7Y~GFK zUX?LIQw^18ftHeYw< z@OLJYBK0fJh)f5Fu{--9H18Z!wD7zo))=M+NrmP4bkZxydd3Zx$Ug*fs1{D-q2ALMUp%+^%4Ha-VqB#RQ!%a1sfFud|zF!0zriSXXUW3^jM@YIs!o zWn6RO_y7<z;ryAG-0p#s-Knnhep8!o+N`Jmml<6~b?@r;q0f0`NhSxa z*_4JyRI#%qMVuzvM&j%J8O-SAb(m4W=4Pakc*4}oY|Kgis(zI25ZDL|QC=u1V8SP9 z_#<3oWh$WUfPg3Ap&HAl!b?9NWRz_?Ymc+nyIy+l!8)ZZxoRtQhaPRwzrJrb^4XcF zdZzxCXB~z7i*)pQ3#uY#dZm0HTge$^MTiW<1Zh7z1Rr1u;ZUVU2?;4z+G}ODmudYq zKCl1?Jcr(Ep11%(G5Y)A1SNO2**XUvL_Bn`01~!>mIK=hhja9DbA8ZA)@+w5aM}v` zRF_QH0I7SI?+kyR_SgMScvN*5eBAgT>_kK6t6=<%yubI6yIQK={~9U9TO_Zm<7SCt z+YwXepZYI`y+8>84|zkarTi*UXxBg_WChwxY|3=Tbj`r6`5ot*bZ!tdcB#*VN_#+bnPuJ7(Ol-qW6 zs8}v?3~p;y+rCnF&|{nk2@1KaPerDmY?Ymebk#4_Sx9~3F@h{;!U5++7Me*QDFR4j z)Oag<^_`*l8-Jq8@XGD@%FET{k|W$7m6!6V>x2;)NCXGo*aRE#z0cm~FlCIE{V)VE z4xQg_9>Dav$Md^q?tu1Ey&!V>YsOo4WJ0Qj39f0)6Hhf%`Thq}(;jo7LywI>@Xyxx z?m55^11Nb^*%1Iai_Ty*h>{}>?%KM~AwUW^2a!2O?L|eYOhDcM6BgO&vPzhJXhb94 z0E@CK=VWe731c=S(1ijf5}7n`vevVYmFtb`yp9r^rzSmFr~^J&Y3NC4)|F0lvu~0~ z8=oWVaq+tAVCXwo66TqHWRtWU9{P*)_Ylz$mFZSFb!~6cL&K!BzdY1SG`Yk^&+T-a z{QJ`IclnSACa^yX#2xdua|p4mc68~9G12XOpHp0K!^0TV$Gv>cYWS*=S@7hL|GM*mM2dI}Tnp}qu9D;;PyzrV+(cg!cv6o7>VSGh z04U}lLJ;>ScoOOu2r7-Uqr$s*89NEZxaEDz>8J6_(KLBzl#nP40EhM2SO5!8tCu(P z9QAAOirFF39LPx@a)9|nckv-w6%$xaiYJ&Bzwy%vLy->{l2NAk$oD#sdM{BKn~=E2 z%x+EtTdmBwq$Q8AR_XgEe@_9j=X|rk(=f+wAc7H>$W}GqF*AJ-2*=&ruQQMU9tE_f zL)`ewkL^MRg$aQ$ik_fcZ|E@<>T7JP5t&q!fQ81r-X8dr>0fJ>%lA|O05@kLWx3>l zPDubkA@Ke>i7iyAghdcOu1x>D0JZ^I~%t#i&+ z002qZ`F0rfs$JjLp<=;^0QSQN_@_KU8e#~+QRE-j-N*O!zAY?G&wMA;)u2mU&tZY_vY~&(m?0|a2|Ymp zPI%#Fdx7vi0|md`Upf9WY7v((!nz@=wM^XK1#79bNkV7Mx^)|sQcxw<=Ld^LZD^oNi`^2t-d4P;+~RlvX3GbA2)-iaU9QM zr@LYcGwF;-$~`^m0k?XI7H+)_0Z)$&-R?EgaLMO?5T4u0frrfTnSRIxmzR#hhmSV`F+6v-1YZD%d^L4?;3yhC(dS2$bRc=U;B(|`BHcG(vK9D*4;I|tRGhQ z<&`v-%jqzu%_(nHb}Re&_d4zvN9HhoW?bCdUSbZXR&kgo_B0z?`l{V6-=B}%&o#-1 zb)WC4`B!-v?S02}_(?K?M(`3yBepu6i62j+TjXWi)85%JWHy(%+RBX>05wvkkVqrP zk9PBKc{VXyNDJ)V5y$G;K!wc@e%bwdWVX_uFyNr~t?A=CAP@j0Vxtt#hkXEoHfbR` zYGXN`afnN#wbLUE`H3(<-Z_#00VHa{10&J(Rdq4l8K-GX{_|%2mb|ju{qyN6_71;) zWlQ6A_2-UA00k=lN8uqOSC6;!v+L4wTpZcrmiE#AsyG9%j>GN$yPZqLGOcNi1PQ<2 zu$e#bGM_oTR_~&jIihNT>;MVS0sv{)dFaCX_5Y9&0F8-ME?)Z-tMlXl2PagSB&(5E zmHvl*kpz)8wY_a&{VXa<^k%^X_q7R?-APHVypwL?+d#4i2j5Q881Vz@lk%|dEq|PM zO4jL3t?wgbsGvX%9jeBryj5a*AL}w9=VT`k4V63DkI7LRq__QvMqlTSR3`-*5!#ptAU`oFTi1Aw#7xb4g{NSd>&u}f`?K;4o9 zPxbD!a@I zR{9V4$?N>2tjN~Md3NBF#Mx-qG|iR7f@YuCnWN-twCvv*E6BLIq-e{t^tLL20g({b z2C+vY<1g2_TnPOEq;Az5j0nwpxamT0uGXY2_0RRZ#9)8Q($5T7I|AVUd>>uGj zzVn{`=c4bfSfI_*%V{>wa#E2q!Uf2@^+HXq3av-k^i8Q-tm_2Jx2Bjn+k2C{!8 z^9i17!R)F-IqxTHMu6x!Nwm#3+ za39L=&7jB?QZRHss}y#Bmjk<1dhJ>HH(g@I`ZxM1-@7KA(jzoM%#j8&)M*{fVp?=R z9!8`sPyGOexK{zYphi=TpioNKKgdRD%C1?@;wSaY**1V7K*cIJO^=We{;?epj>;iL0m5(J4Ph%zGx zD+me@VFW{JcX&#@LvHr`?a>m!2$$I3G*&^&Xt;0xy6QaA*0YwF$6;Z$4S$Dx)4AB8VpXYs1Qg4pWj}1ecXAOb>0se zq?7DFvg0nF0=%%KtP+zxb-R)X24riWWLmeqJF%$i3QAdid&M0!duxO_Ck0&w@W!-* zjSu69wp`WTThO%|_pBc(mLExEw4R0T6)DY^O6wrfi-Hx7qh} zahL&8d_@95Ld{&W{{G&fS;_5alz>1HIsMMI)u^EKy#GdHK{v6)SX=4H%ErNAE7bg# zo%t8Sq>BD6pN*#;!5SZkUD*7I${8iLvQ@N1K_ItLZEr zY^}-DGo>JSkxTXj4@5)&vNi=U1cU%1f2p(bfZ@NV{=>)eUQ+eGZM-jclc9D^vDNBx z{B;%l&m}7_8-MSzHGJ<+)7tmo2%x{205-4ygcJxs2tW^}0Yng@3J3}Xh>8+QEmjdg zKuU3%u`bqCDu8)u80}hnMLJmTX5t@53z%B_BikPl`MnMqBrcT=o?_r`dDe zWBz(1?q_90eS4Otj8ZeBz5`!>&sv$|c>ZVxFJC9|_|!pS97(MyYS6)_*|F1mhv;|c z*?d9HP;FyAwz=aQ|}@{v;qJEDW`ag zcf`hDRmHi zuJ7oel7KCq`Qvj5U==K+kObR&_X;?IEPKkLKm|8GU26bl*79N4BO^HFvE z-@Ue!RUdcQvDW>nCLhgaY50_HKX+naMQ8tovqUB9KrNuYH|GRyZTqL_phn ztfHDAE&H3%U=nX|0Znl?qs*OI_s=YU+>%Kol1CZdC+E}SupjK8=V1 zbBa+sL_~l?e-k~1xt;F-Ko6&_(I9gUq0}4*LUK1Pw>FPxdNo6ex2|?%H!A4yllFAb zfB+?3%@)B0{+}@zB#|KCh)>|0l=ZqPjm4uU`CP$RK_p8I4?t~KL;x{eFN2rzFZLA1 z(2J{$|DzcA$>e0I9E)k;+selER7xT0FmtcSjQYGc@_VzY`kPP9>a@ZOjSfQj-@5p1 zmCh*x;&Qd$tG2D)UR|0<%~8GTpDjY3Z^aC`t*c`Lx(E;;K!E}T3BB&49Ak`ejxojL zxuIgUQ7KhCU0SIXXAhW)_+=Iro&B{AzrExDffH4i0t({erRAt&MCDv+Q$?NdXej6N z@U}gDk13yPy2_nNHO%1u6{|wfb?g zh27|~w~n8lt~Q-b$@l<9n?q=A4Z(aj1EA0?InHE?(PDxftx!D#QE&Yn^VVJCx3K?nav zCR!{WQq}AZLx`mph|$z1 zUY;}&3et$<{>-0qK1`Z!4_0+$w5Jn=w7mX(5I_V@dmg^Uix`-gl1U_zN%8l=1p59S z+tl;l7zh9pLE^T-u2pcC6Bh52H+7Xzj=k+K(!Nsoy6q*o0M5l?& zgUvL+QzYrz)A<{0y3gPd0whnbLuugC{{QXud`*$4zox9GQI0e4*&rGL+YN9VB8c;Ndk_S6j>qQq&k2Q1V6C8%iGAkcj@P48TG%} zHk_6SB}a2FZdfxqKX@PzZxX6sr$b;~6QXoZgP`a-4uhcRYIDDU^>G!5h=AkG%;n^q zk@73ymufF8mAO}ES)9_p>DP!)Xdpyh8-X#VUUAVeDg2!ke*TMYqQdKsLq?U}N>vv# zQKSMD6H|2Atk(a`)Z1(n`l{|I-F;*d07K2PRU141>#=Zap01@}VXQ}(=B3JUB#m_* zHWPC;_~6ul?eb=q*l~ddV5nK}@P9_@f$AH4;&Re%PJHH0DCfx}l1U`^ojJAHdMMsT zM@{#>-}^fLVT)~^HJ64#+J#QZE>lCZn;ee@R|CHf%t8nb>el7h8Bk{uP~6UUq}n#g zcoGpV$NdW#%w4pGWv*W_IV97-cw@YKQOs;qf%zTiAX960i0$U;xJDvBn$Lc*^|LVe zJVvhV@bLU!*boKubI1jYg@fM9)I6*f+X>@Xb+}0~&?)8^tQca1l1U_zf0vj<5a6@< zIC4AR+1YkKht>LS(~*tGd#^K=6huC5jC5?R%^UxNa0mpD2%3WOacAKbt`RhDD{_9E zv<68on#*}az7j)-aSkVxB$7!al1U_zNhFd zjW(RGezG$kYzd`4SOY^KCVByz;6x8$*oazwS)7g*k>u0&@wz^g1JbDx06{7Y{S<|B zsBf_5`9Ce*`i5?TcfKCImWNk4v5}&4kNv$75eNL^FxUVA0`(*{MP{1xx7UErdaT-& z?B{qb&?lN(o$<|i6`a2ZZNbj=Q@DV{|}(4hPS$FE7QZ(|PnJ=^~@CbS&%pn^v~o90ShdC32=7 z%Wpp2p|X^Xw+X06!vQNt`Rr0A!pZGU3^T;;O|i`*VI^KFs;*a z3&es*0(k@ZVIYzNvIFvoA>{#ex23_MV#Q*P+q?VRzH?ZJB#0g6VM5cQ!JcT{|2;AzlTDhz4{cQiqFg!d9u( zFW1(P`Hi4U|4Mwa=U&r2lmCzUb+Ky#WsIenZX=N$uWELw*R|4NgKk92V-1Z&X$a<0 zEhFT6jpA!agOVC^JSUN5??=nc|2kiw2m#h!Mbc}Xxt%P&OrCgF7#AXg4{fpc)7&t&-&vW(*jh*nlbqm_4xK#IFaGI9!S#PQ+pcPP{DQ8(!@!Eo`zq2b9d$c3t#l#F+WNDdUk98Ni=K_Uw!jk`zwT$ zFJ}#%gOaa;Wi(B5eLE&kvS%v2+}>Z!Jr8WnkFt#WO5&mv@;KVoPZvNJ-I^D-k+-FE zi360_j(oJW@TRNF0Y`sPusuZDx016L{kEP=zU{0H{u`Nu4qemoGGk++U5xU&b3h$7 zetW?Usrp3_Kmh{;381Gobu$ci_f=Nm;Na&3o9)cI#Gl(*;{?eyHmZ zE^>Ck1Px;d1dXT9nR>+mM~dHTrg{uJeD^~8VPz*nq0UsOQ{}P#s(j03<%_Jlm8K=4 zDg-le0!Db`&vY9tN;*^-a{`%5m;ic!0i^whQtte9jv*;bF|5Rgv#)jUBk0a?7(cGj z>qL1rJlTzzHDQ-EZf&Tc4M1YO{}*`AqxN zQ+{~w+EWN5f-p1jt6LeRU;zbr00AZF5+bwruT0Kgc z&dY5LXwz>n2*$D6vf6Y?u<4uo2GKm9JUOpjn#7<8Ad+3(7C!4{d<;_AyvNleoz#X^ z@t>}%l=EXtZ$BfT*dfk2;-}%~*{*R_ZzH9Mx9ZNXmNEPH=067_5uoZt{qkk(#CIj~ zbCyk|NsIny(=4$5$BfRI8kK%C*RPiY!2<%GN5H)ID&TZ)jFX_%QKD9#B=uw2UL6L_ zxWz5g&_vNHdfVs}8lHIXsIXoAazJ)$w*S&~ex){wyQ5dt-R)3l9=V>~`{z!6^fr=i(wR1Yx&osx z6#2B-TszSWUDU$>AdfV*hRvJhs>FX2z#srjSG}HHf<0TCA=~+>Uf>Q|Kwt3GtvzOu za*is2*IB_xfd@h=58|qc5{bRPM5@+pKURy%*2(&9SE$##UGMYaT`jnr)e^U5!CfxD z9$W#9IJebR0yC((^0mpcxw;y-IXmkC>W;AYo8c8+M z!a(XtrxewY2#8SjgGM{|_B!*%Q%14#_o*EGcr*E))P8->tBuJl5duTv|H92ZIufNW z^Dp{>R!~)m(DJglj9y+*UfQAshT&;SeyYw(TBfoEL=JZgOHMeX1u+&|7m4V0Hf;89 zlXKerv}7nC2Py;pKuUxHMP?xNp~sMfAVLs?1`vcGLJ)*uBM3o<{={TyHI6o8_3}R3 zG~c3nb^QjWhpD(upTPnI2m}ZaAV9>~b3C0dk5s^&(EkwZVDj4_h0g_jvCBY_9%C3*BkkaY=dE3 z`wW)FFu3h&Pi1#>38QJGgLE4z{05t)iHxGNf&5=D-V3t{_n@?A00J)891tKZVLE-M z8w5&~o|Bq)*_XyC{5CwwY!Wps>c^AQ+sCckrfmwH@=CII`H0tyM}|I>sa`CzBB4Lm z8!?3c&qh9Ji zr6@i!&)so85E|+qRV&l#Je(Rg+X&0?^1g9!%?5npWCjmkU{cYUN7*MX!hG{my8a#qu5*k00|9<5D5aQarGD=9ZH6QJ023= zpD8i++$SwR<72InP*#*g$n%xiI?GaDbV)Uz z!U4pRNVZkr|9IB8$0xOvoK3>&vUIYN5jiR)n|%BPdRo$P(C=m22Y0tW0_9OgL&=Fr zgXnMSCmUJUu_i|Gj#HygsH|l&J3e;GC{h&yk1?QfZ~G@T=Eh_%!k-%I8=xk4MHz{I z6!u11pj&7YApl6j0u4iltEj@^JzmN;-NhK#E!3SGezLO(jC^Ei*{GxHFi>+V>K?!V z5qo0gfdO-Yz&eK^FI{A~(yYyM*Thg}cWj&w^54+hNhgK9*3{6~KmtyCuu0XWZiMW)WS5md zQ$cE+$6&)1Fg8>kO!H`&z z;;mmJ&?mJ|er8UyTJZ0833md=_TePCgQ|#c;6pYqn#M0=Z9LD> z!$SL|VW|0YdePs1S+>VaTcxj0%r_*fz~%`K3qS&V39uOVyiNw5OJtQfv06jPF^bb) z6{&fcb&94=d^(fIDSd8(k6fHVTX((q&3CZA;3p$7?iYx}XrWVtrB9FbOjb9Pdrd3I z1l6byvr0&1Ct0QmrKy9F=Z>3Airb0imDwbtO<7dw!J!5MAkM!=-C%B85a`dQ=yaJJ zjHB5VLfZmh`TB=P3KWl8gUXIn?Fe$p0stJh$XZXY*jN&tu`OxLd|H~V$QGexvey)C z!0Xs+5f>GCeyVEEaA#<(O5bt$D0L}z>0bW#=!bHQzBpUyY_eLUAhlMTKoA54v-Vi> zC5f(c8JBk- zKPEY{%aC0$>&s3Hv+^^00g-&3Q{sr#T*xo~6$vvcNKJQOpn#NYrR2~mr|LIUF5 z^>CG|Ru~swJ>Fsj!jV$|Kohr%J=$eq?pZed8A2^kyvW~2|F1Kwp8k3uR;@7Zy zTjiykqrk8SSFUo4>v?2b|IJ=)(QW&|R?o!WmEe5-2bIym z0t8ris~z+}0VjzPpjMA>_V&i!l(5Mp_X))xf3Qn#N{&H`uy*8c-4!f$t>@nnZnFcBI)#)B(FjeN17uJ z?D`LS8MHPzLLww--jkP%Sm)$h`hPxWzZ&gjEdoFQ2l6-OZB7y#nzSp`U;yWxbR}T-QrA7rkt{~*uqi(;7v%`;N zP)lb0=PIIaWWD{xCcwB7h`Rlw;(2VSzV)L#ceg7D%HsQwRQm*im z&ivGtk$#{1xM=bXMr5T=s~1~Ck8vBnBIe#M=8?(*ZLZ#)jr4#Z2C8Hc5dsw)PS2ti zD*veTq#-grs$ETK0uB|A2EhU-i~HF6^U1>v8MSLQV{(bP&))f-84q$nL*XK$OtXNw zHifU}w>MP}SCn(MYo+O!ud$D9`E7nDV>vdi9FkrqgZ64#5T2or=M-dl4 z-*toMsqq+atogjwaVPJw!9nsnKaTf;37`J={D34MN`n4*%6h1hNWfx8@65EnBTIS> z6_cAut~*_WIL+oFZ3?e1K7kv18LM7X(_s$JVd?3~XGu=OVcTU#_%QR2x{k#{Mip=p zN3p_2A*m3&rQB0;V;`;4m zW=`{)uRZtbg5va@vh3-M-ExJ_RC&~B@vu6sd}`zcFXi{V_wKomnX)xaAKEKsHlN&rkM;i3t#Ri^LM)%K{QS^xzj=m^U(cV=PD>1O z`v#wNEh=})a9v^XWd(Q)vO_yQJFzQ&`zrVmG1}d1r8#t@00>V&BSYzlN%-^?yXqDA|PBghrdfF zL+g2RzqaVivOOy7a;hMJEjR=~vm52FdD3b)gj37XOnh4wCQA@I-;OLQ&Quq!b(s2f7fL@KR}r6{yLPyH7j^8`cJsLFf+VAKoT+l ze}I7nK-vO$H_;YEwFN3OXn>-7J8F6$N=|lOEeZugDMA1j-r7V8W34?bC9c<;v25zr z`$V83epGOZIPB4?hyXTMSJq7%u0mVi^5L{*EB=}>ThTtv$W8QWXn&1n`H=h;pxb_u z%$oaw5&Bo1#^u(ZleSs}FIr&%PN1aJL_{~lamuaD!+kldXZP_uN3a!2mb5kAEdw>PMgg26su!y_f(%io%`u zccHj#R%q7ZN=3S65o7+-?X1s~{-)r#=V@U9zgCXb^ZZfCNwX?Cel2Mv(Ap|&YRt8& z?E+US6hy{}Xb1o$wuPVX9sk_ZU2l8yo^pG)xsveEahE$9-RGXp)lcSjv%P;4b>Ql> zGIQL{;-09Zj`ZS*3pRZ`c(t-*k{|(Rod6K~-~xemgWBr4F}n>V8rd8R9s9HXw_Iux zzJ#q(RMgd}zai`m>s4vEJSz%NX}OdT2|v%nI*!1B0sY|m#;=CbI7pou{Ctz%9Dn{! z?!6vL!J#nE)3gRhNiz3CpyqEz^cV<)E0$f-6*8BF>}Ka8gRM_%;(c=QxgFKDb6JH! zC!-0~`zjF*76VFvHpl@2fq9kYNpRO-(zS&*D9L+m{xCxceyv zQ|ZL3^#ZjWj<(-8d8cIV6$AioElL6$=*oFk*aK2%NGV4Mr!*Tat_D9HSM8<*$9OhH zN<({3wm;zNtf;o^n{ofX`-f9okMcVM!fKvPt7*_l&&|ZVL}z~lkVVFkC3YtlEfWyM z`0QR`pz!G~cTK5$R_WQEQ&!g)0fUYG+aHgDG`Y|Z&8SpSc$$^gfJ^vS4cH6Wvt)KaQJrK7Z;t&f=J z4(Pr_|5Kt+cRDqdK^>>(Jw0VO-c!GEiQ0oZBeeNXauzPL^x?}HsB&5{+)RqeFsPo> z&8l_IM&6nu3(oDSD_A_mp>6L*NeW{yt+9onpWo0;FMtRnRU8fxC|S-^;wOC)SrDv}9sqC= zb#rAseJrun4N~JYqx(igiZwYS5KNF++P)lOOTjhNO>h7R1^Zl0l01|%JOnKAMLy<= z0czfH3dJg}=3{0E5c#;>oT^guJe-|bo;HE)j%l7jcb>@b;O%8DHoM5^E_8KsXXa7G zsAc+h3-@0=bnez%q0{=Oe=ogx1$mi=}g3wmb~ z7%y^IT&AN_W&sD}o31sV^)X~>eJLq>Q*!SUko7LS?fFwr57dT(gSA5x;?*xb1({Rx`1oR5zp45Y42c|H9cSQ<&we*!{KobAPx)>wzfa>>!~ z{?oY6kwN|D$|3QhlU0~AK`|LiiWfE3!+9uzU@*jY_AuF_l3+TQO3Y?g{8;zMA9dd5 z^d@}M3|MD-+w~}FrB&5Akojqy?S~4q5D$0R4R!BHn=83c)HpCY^I=~@dw&lW>ptC` zO0<8jVt-ynpRnnwCy)syqpgGCUcv5U|L*?DRwM3-h1VIeNoah0e-s=US6^CmrlTb8 z!F;c`m~>gsTGdgY2+>SdIorQv1ixp@(FpH{@HcAjDfjBH-`2Y-uEFsX*F9lY^4~Ui zB&ky6zytse9!E=OVrLG(a}N8~o=Y17AN9d{b4GtDMbXgjG;>T72=Zw1BUS$)m&O{nkN%5@KrL-rl)(&Utqv-1F0HOf1DEIyT=@*t`G`{b=c( zr9T{LjKhNi=OS(6D>utv5h?2YI9zj{MLVZa*L}Z#-|SSP7PuCr?D=I55w5w`f0X5% z{j9__Zu#1@>i*uJ!Q}<+GdFOTfd{9}cW<~CB6w1~89pUkwTcqxZMWD2$@%lGa?U?A zV`Qvx9OL7vsx!17yuY(lK(x{hFmD&!F^p1XK7);kWJYoS2rjGix}w%e0lLAD)cO5* z&aEgH8Tlr}sl%Ha=sji_(-$4r;5vZ7B8dS2ILpjh=dKg3T^@s~mJcjrE8Rc5NiDsywfcp474F@1`-H^Cnd?Je$cc zHa+L8A0#rbQQ%bAr?-QPj^jnG3oBE04vH+~Ny~`msOcK?JGni_7b9J_(&#c%T)74T zK%FAup;-n%07dBIs)(b64j2IvS%3g}&F1em!D`fjeom@2yvgi>qy?SQ*2%(r{ymAy z`c8BN0~|Gf(fyWXqF|xHYd_o3*!?Fsqr}T-p*r1UvSB;B&7*4*bqE_AjA$++Bjk!w zIrO9Wzd3a_SW12z2TD8EyZitGO3&PI^4e+=Ns`JXWF+ZRqF+)n+daEHL-clsW4xr} z`&9h6Zw1`)@*6V=iACG8n$rg;XfbRi!~l<3`OmXq6QdCHxs@`#;#o8n`Syt-k5jvQv}~Hi@v84}6+zW%gM?L#25zk$I`{HPG72`HM)%wn zcE`@Bol{7pE4HG++C$Q=R?t(o`_wDOXWEY_5FilMreIw7XX+xM;ccB2S{!v4lcB~| zDNt1i z-?QVS#mqgC5F!bUP`l~QzNuQ;laILUAL5FUb_@hUqKo%Trt=lq32B`vJ|s8IS~`Yt z1Iw^O){3lm>3SgmcEjuaX**JRjaJix4!*YGEdD|uzj^r=MZjSyw6OUr%JAb0g zMb31C>hu(RTFPbw$zNs$rBa^|qqKVz9>p3?}qeiLiZvN!!G((=lu_&b{iAk(4 z(ePa~&$>@7=9YF{G9+v+e}0?XiCDTQ(R^DdvR^*WO_^3xB`-siK>z>&EyLFj(eNLN zsyjY+EjLP!nt;zu*3DSh^K3BvoGy|Ul+5ed#<`mBnqvC5Nz8rI1%3cO_vdj+L>s$Fb!MBVeA^;KK? zWcN5o*m9=mba3B0HzKN_0GcO^G@hM2r3x0N{K`24|)QJjisV=Dic% zaHi}@QkqoKPn{^7u!60*F&XT?|wJbFzcecRVF zIaK-X=JTbU5kA19ugP>#S*d3$7^Qym0t>H!(fa2uJiLFDml8h!2qQ$4EF1|0f13FS zI+%X1FFj0|hPK*3s7B80AsYOwOzbe&FDhKKx$?&%0EwnwZ)EwhmyP4HN^GODnJ4-? zl1I`BiZEg(LAIP!=ZD)u7->Us?ZsFe6dDK;DDbB>?d^=rM-1HG0$w6~{n8tf^oZn( z<9Vs98tlgt8DXnT-@;En3hD9jP{LTWU*bKg;{$7>_$s?Hdp~K3t`SdhfeQ5uUirF0tI z8oaKFPwb7Jbsoe44^`S{+uo3OHo3b2`M{(c6OhuoJeAr$2*5NsY#{q!k^nd$K?yic zqfc7?GT#zdKvMzr}TdPk&`wT9`2f}Y9!IySQ{5uyyf)}mt2%Ls@M#My5U=0@UM~GxlsRbo7T!NAVtQrvB0!Qr zG1FUqqn;=N1*PN9C%_OOJ&NFfEuT;2!kuTM!?5EgkAp`GI-yd9N9gyn!_~Xb^=^M^ zV-UfjU|M1fkF%(`CbL0=z1FA_swDC5>L%0f&qi8NcxLY)q}89npaIA1L6Q1 zv;gg(2j6mt42xnQ2BX&*2fv(T?sPNiAKKQRR*#2jy0V?SN_3oeZu7#KnuiDo0Zf_H z^Td(6mz{g-2+*V9tb8Z(*QSt1CsbO~#MlFffCLAEe}-&<5N?QtW5gKCWd5lH6QJTol1_pPfTjo?=_}&(qQ5a+ zoPIxF8vZ6{kJfvblEw8D;gQPE2vwV7l_+4G{HaR~ZdR3to53|W5r{?J@b@S*@;5LH z)HvMoNX`l-Jb#vH%M#TRo!f)wFm7UZB=}4#7Ww!KRcO|5c?3WAoj` znv?pon-l;dMF4k*091Gyq4;l;b#T^tV^B60p?jG#bMwD$Su_!1M`6hRY>sOUbq#1KR&cK-*P>F9Ns&xav}tkbre zXPH^e0`luzPfx+FXY$Ru$35P2`(cvfEsZ+s_;bMBkTvg^y!2c!&utu@^4 zrUoxhZ}x@O-_!NJS*4t(`3bT+aDK}AEneNFEkm+lowpVQTx0-^668?#5(kND0R{e( zU$y&OxUjo;+equwiuzYsuY&+U21F~cj(Tx)(pK@)?`?Q*zGrLs$uk!JAH6q)ujTao zZ0>@8pG~<>h^h&juko)OZ|fx^9b=Xd3pQ^HuZ{$eK?DN@n?{q!OZEJke^}YIn#t2PAlyJTKNcw@G|neKc@C6pF@T>jZ3(2_jnprP&JG#v>2j z$)rwi3DHaF2m)U3>x1FWgaiQT+4oc?-`0-7(^IhRl`HvQZeGXBiRKh<{bBkBo$Nw%FQ+V} zb!^7b8)3*~pqhTkjqtS#N*4X!LG7D}+0ReOYh8RnRqeOa)yqFwEFR7-BbTUry95c? z5fA;B+POz}^Td)!BZW}saMf67C=n|F*r-BIPG3dXJoYoq_Xe&_^%?450wajd;I0nbUvo6G1)99 z?}r{rSO7#rRM_s8st$*9M)}nGdf3;tFEm%)=Weh0jn~{~^~v!Jm62=eaW)JUK#~EO z&raN6u*yiZm-I-3$CHoIpj2Qt9boOH5*<=o#{b4 z(48?y6+hCwK%>R?wX*n_iJ;HQt@U8p6!^t|-PSdBaa^DNjTEYnS(&)lTZg0uGszMK@8M=?;%_ ztlXLA+FT_&cCZJs+sf-l&m9a{DVjWfHBSF^kc$t;D zIDF;>lL9g$$P_s@52Z^+-RDUQpBe*?3#T%?;H4wMHl^e2QcsLkx_PAjAoA&^MaY$3 zt2Uc@(bJkcs>8bn9fXyrZMLl9*&9qE371Qo5Y?714`s((|G5%!d8#R9d4c|8`?d`E zcKgI;rUUL4mQWO)z)&Ii}-p}*DRAb1pgYB;g+I7!)6#h_-x^H#o()pbo%7Pgs zK>01!tG2_s`M>X+2m;M!`y@rqYB+J){^LA4t3oJE_l_F&7`h7cd16=FfcCN23O_~Z z(DKC#yFGZVP@0D#Q?W5oJ|Y@9;M?t9A89@5*QJ_RW`ceM2#O}ZacQyXCh{xZ)kDAB zCMqfFP}nDA_r}uY1kbeRu1|1vRYk>LpwD!C_x!2IYLw!P|1l*~$CKW)IDX2{cs4!I z3VF~&2*G{@0fL%XD6fC3!XlQ5K7T6L4%iW>WxU)M8{q&+0EK5~4vKSU3^Zg(5`n2k_wsu)>Udjx z+W38VChmS|OHd>s22`YOaJ~&Mj0p#@wcc*k z-U#wN*90%)^`_bb2tqmrRKx}VK6=l)3Bb0@(%NxLLocscc=?)4*3X+=GRy)5U8P{0 zxUmV3U^c*yuEPuB#3(Y;B&+@YNk-FM!`*7Jj;rNkM#I+Ewr(cHfs{A zD#6?W0A5tDn@PZ4(FgmIpfrcq!RWKyPZOm7>wgIho}cjH)HiV-d~GQ402VLW9K|2l zjizvRU1b|@C!*c2Ii7zl*N`*g85Jz*ny|hdlT7KCAo{|fM_vR;foiBr_GQ2#7T!wW znYMYU=ILA;y1nHtGc~vb00dMze?4|eaJK!8Aj0t5~9hj*R%fdE<+AI{VCB{O}o zqs2&>wIhzH8T7{fVT#C5P4AKNVSUhNihIwp{AqYbdK|k_rSf+agvCgD$7LKTzb4<_ zOi%#?4Mp#f)ra26n^=2KQ2Rd#7y#T_xasTzjhiP+HiPV>0f}Oo z7lxz?MMGX^{}~dFh8AWsx1;LSD>WNYsJ}p z6Www*8^2t}o!OqiVH@eZdxk2;?g~nFm(T1+XyDj+Y1h-#6dMbvM66H2tJJMI!%3)3 zZ@G8U2L_GHZ;Uvdu~q~DfIM;>o$O6BOeKeTbx_LEqxy5zGSzth(rXuNJL zpb!VUhrM+=tPTed$TV-A#7vo}Ia>{l4=c8gcE^+Z{_o6H#rn06r*avy$qcKI@1eYepGis{PG2j08kP2n5Ws7`{}~>DWihT!tSP{^Z(w7-yL6J8-J7nA}ajXreoz-DTU!r zmDc4Yx0~Nh1damo{41x?lhd5;_uzU+wK-1SYbf0HoU8;0 zx*gRdEysv~6gNQEJ?ex&_$h2*)31mS9qGUm6mNk7QL1!$GnD7OLwBs1WNNx_=&`Lj zeR#%!06>636xG#_XHB5Q&VG+&&31THGg56ij!#*?#HUQOqrr(u{I>Nx2wjVr`Teu2 zC}ErVqZxcw5s4wfV&2iWDN{!P2^zZOh<)}YC;&^Z&tcD7sdO=V);Ig;8|55Ee><0r zY~-8Ig`D0ed-Jw4+m+u9+`m$d-^5YK&E z%DTTr*IbL{MT;$)xUdlgL3@BlVe~B#rXvk4c1>dODX6XnfN}_`q2z z!y-Gp{aAPITJ z=BxN5kWSVmE}HQxg$>9A4#M-Cg0u0|(tV@eWL+AWo06olSWRI5aORCKWP^_-Ts~5R zXO#VSxwog7`eZo!GFQwL>x$T>e12wu1Q0+0Gk#^vk#Sy)JwVa_Gw(D#nb}G>+P?VF z!C~2mB#vMN#4oTu^mIu2+*%7-s(=UpE&FlSL-(<7f4`R3V6ciG$f!98kq7hd?;fQ; zrCq<`r92J2f1iY%=<&8)36_SjH#b)8t+H2OAFbd1vH$?Zq*~m)eVuq|bXAw#(U)?t zv}-|k`GUXxc?{$H{;rPpKXDnQU24R)@;B??f;xLq4ge&KI{m}mlhA}?FRG;oDE^;S zWbS-Bm?=h8c9ie#$*s~}GoOv8@9A3wLSo(K;krNR_K0xhu$U=5&$?bFR+It+FC4K&PQ3hXA)h=f8gxJ*su;OrKqD z#ru&D_Vvsqajx@jthglI0wnN30u`{04M#B$$=RLP+|e!N_4{p!J~`!IGitGPnsr|mb=l~NK@g=(K)B;EcPyeuc8D}5e z%_cMuFK$+FPiycn*UXv~DKg+7fxk>(yIt_esY}v&hJEY5JWv$jH^lnvzfMdsO|~p? zOY%Yh0Gz&$4tBs?s<~djH?vV6mvPrG+hCODebD^r9fzj;v4uwt zxu*oQJ`QfdHkXdZ$L^8KeC6B^33+pm%nTBzaG5oV;3D9!dV zlvU{#&+Ol%%A9l0EndeP!S>An03~%BG%oL^2pJXK(tL7uc4ud(N0yx0b9_13#yDC2 z!muO_t)#2~c&o{eo(vK_I6wg`#E>E%4Rfl0*#9O@!8sqoxAJASV}9o%?kU#iE2E_- zH%;IBc*)OTbI3|~XHyP@9^Uf$7(U_u=fQC;H&NLYm)Pg#N7hIxh*|S{Ca81tJaziYdtkcMIi2Ot}?s>7ynUneRPcMJMPcUeso#X8%A^->+ z#d-7$&BG`7{Ov6wzD-}Vy@tN>7v5s#sddUIeZ?r#Q8ibXaQoRAZ%{^e$9u3Hpq0 zZT_%=386U>8cT*ENdYKd`fQ8TxgO$#7PtUG2j$$s z5;C#tTIW1sl}Vjj9@egIzCkZC%99B7bS1Q#n$M{>u=ldaS24+AI%s-1x5Zdp)t;kW z(#_n!H12ITll`U;uNz&6lR9PR>CZKfY>^6z>~7l(^}Zfu%{O;!I15Xwz=BItBueuS z2^NQkVvtE7J$~qyR{M094eY$3JJOEsGD>k@l~HTiT*D5StkA8To`g+}cN}re8r@P) zXVJ)EGu2%a$b|v4AgGDr8s2jdCqG@A#3E~~99k;5FVA9rUTQnw(p}<}AOv%T)SRUQhFH`2dzS)NrPby9_n!DSgTEcliMzx@o_zHk}f9y+UiDox>FhDIv@Q)Jg+s8?ynkBV(UHi&f(L^y5&%)a z4rj?3P$MP62bBR?SOfXNKnecpU(xU9HC4aILC)KHobKIG7-~;Ezm+7AN9RvRuWWR- zrp&_8x$K!E67sjIJZxD6j;AAGpu$O~=`J-jIh=>D=hJqF#oIX{6jRdnTgh(JpFNF> zT@V43>En1Us5j?gT@b(k15!TkMz_2GfHb$Zga8=$x5acfIC4Ne>*s|@H;p|lTNkVI zDz$O+V1q*J;#0u|s6U&GD=Lsb?YU%@g zt=0Da@GmJ5KFW6Y&3a_ReNKMcT3tx$@q;3w1`GoIld_o-QNuXV2!^(R00^NLZW=-$7jfb6EZ$_^z79-jVs&{S5lKn@_KsxgASoQgZ%MllKOs zIyC|I33Os*P@a6nQTDC;;%&@3)Jm59kN^<{SY09fPjW_sFqxowc;KxnXk6(TwK|;Y zXAe8D-D23Qv}BM-`Fzu@=V(UY-+9Zt>M8UnfB=kYhf=Gsjfz8_{^34AAe=w7!Yw%# z&6B%t#OG^mmAKsLd?QP<8^c}C=yAy;=WQ_||Gx&CD>A2B6#t3!#>CcdH_a-R-~tKu zEhJ@-!u)IF9{*lw#K-LoLhsLuYT*~3f&1XfTetACQmA<^$>bU26&{}mUh zat^Rph4GWQDes=!FI%Crlp_eDN+pvxi9Dn)A_m;>@C_B*^72~J9(u3=0aUMU{KHUy z1QAZ_$GxA?R>MEgEBu!67%gI_`>XKGsqfRRtVP?KtV9A&(N{tEhMOdjk9x+cfJ920 zRkM@I#n$4{=WkcoeZg4ra9d6yqp=`@SBW4IIayIlTm?2-8`veSKz@k=A_W`-Tpms~ zX~hs;>J4;NEhj8#F>{O1V^t6=kIIQ*w{W9yF}|(&EG~rcd_0fi3oTL zc+BX_!nB|+${@L>I5!7vHTEsd; zS&6mG#{>Wf6K$7@JJALB>%VnW6jh=x_-eYfU+VAt`I$JK19Tw>K!hO(ZFilQxa78c zk73mt=?FpuAqYcDu<-KzQ?uIp4jQ|z0;|yLPDixkIgL@PAqYT(Apt;%S8mlp=Bo;R zaJGFftl<~bSq(KNC#?j1=VP5Dg4DN<(_vuLq<;ERuzO)y8MWH%C9#vbKO$Dfn)X+) zui~<1@#17TzEt7_ie)vo0zD_^jFOQglL-uw3W`bh(7g!dt=)@a0RTWFPHmMMTr$qy zz1T3*&W#q`hoTJX_q3bfthwn8t56Tap}ZeWG&np7VE#=tR(ZDI*A`Gnx`86 z2?RrR;YP4@R+zFbAdt zWBilFip}(j5p{>KE(lhSndk^HNC+4FJy*xq=^zjR+^*JNv0U&;d=#;;ZcMThsPW>F zl|VsZGf6}mSxFv1mP8ast;zZS32&wdW>iYk>DQ(bQELc7P_VuSjj{GcY>{wpA?Z1ZkT%I)IaH=sL8&dO9j3cAbDXEZb|W9OsqJ^*Us)%7n%|_>-d5wQQu43Fum3 zk|)rhUejoF3ZSy!l^DJE?*^_{d4O$+4>jW#<+k~XHq_`tESgt$IHkCLzZ{QEY&!+I zUrOc@C8z`eIsAzo<3YzCT^L;KoEee;48Ck+ZDit$BD1fiDPw7%>qcku?~Ay+S{{_z zSZ>pui0NkY5Wo-vWMnM5uV-#|xBdK*VzD*pR}n;J=cg7BDC2fLPUc)c5C96?^1i*+ z=?v>*eVmE9*uQV@Lg$LjMp5bQ{Fn={K&z1AYw(ODkOZz(ceJLh`|LpWMXk0Ubt!*Y z^I_x)Ew-o~oruf~R}Oab_PQp%SVw8I!2}RYVl&`ZDqc3t7xw%nsO7P=fdUY8-4sN< zaz5bk1QIlj=|pf8^d1=RqsRb(J1RwIj23$BrLa0$kFb2VL1#lOUh-2cczCUPT@S)_hLU%@$j$Nf-BKiGYv4w4jFs=ZNAg2eci(N z)I=?9pUc7eY@L3!@qh>s)62{LJk^N;07gE2IKQb49b}hRXj8#qxI8PEhg_z%Ml^Q8=)T#)Ebi8XY zjn;C@C6cPe1cxvl1t@gD&WE=_)gndlv7g}?6^IHZ2Un7)d1*t+MH*eK3)NaF6BKfG zl$@uqo5J@qW~N{Q06(5gN+|2`9>3p}PBIIh@n9d-`0dKmf06i3WHi?a6q^^(SW#sF zAeVP*Ik%awTPK4^bTI-zLuA4s7Hoj!m(|=Y2%@Rp5m((^UCait?wBgCa0E{ORnz_m z9`izC8hjRC3cTNuv__pkOcM@UocEC_+YpP1~Z>a5B-|T z^10t=?d}zC;RvOC?dp=a)nt+h{nPCdTi7aWu4Xx!@ChE@fFSOH2Be*m1v{1CR{3$V z54|=gmhMXha*k4M-^n7bq3qS{{Jj0&#p62*=9ULJ{MNv90RT;M_*x&+;|B5EWXEy2 zQaFveP`T@u_Rw+dGt+W1=eUep{fdnnbYUO0+dNyfrf$RRfx$d6^!&cuLM$ciZ_~A^ zwf9MP$$=^X0258P{}voNP4-ECg-=oc1ngFopDF7`+?!ZmMjG+hpt|Y4dV~OJ6d(dr z4wyCop*-9j+Ts1Lgmlc!%PqbN{``kJyU?VYPFZPx=tht7By(67*h(>G3vNujbq%Ax zj>xe4aR(=r(!DbAG4oOr^jjZphG$RDxzgX*w>7Fe2Yu__52FY41QJ^OY}EOA{~r$I zfJr`z{7gy+6zERmie$ewKLz&E&XHkrmFcV|MI^yO z9bc(6b~_*tE@rPJ<*>P8)8;U+Yd;uJHC+@GS{y4?oQOfnGCiJVOEcX|xYgkwZPGEVs!)BNS0M}=jM4#nKsu2&nTgMH7(lKF*4we7%LcajC|hu@WljM$4REVCo#~1OW>h zMqlbNORj>SPKU?2fl+edJb8Ol3Uu`n)3kD_g?2@*d_!>J_Yc8kkG5Jm%%O_~zyV63;>{xgbl@T}E&Jb6f?E^@pxyQLDZPh#2hR zqlLr8$}}bKXOL%2Pr3IJb31z3mZMjRsZEA~jZXS^s(!?)MzUyM!dKlKTn zlGyi{(msORg=Gr9RPi(pjHjW(zS*xdFhgAxjr8AS&KVFB>o(p+El+9<3fm*wSrs<- zNAcmY$Ap85>%FxnEbS{i{PPYvbc6{Kjz=d7Odorl7)+&8^}c^iyQYOAD_$PFY)Pfop@3-{js9!8nCMOR@gj) z9Zo)XzRO_nIxCYKfGnD=nDjqihfb1`%bgM?ceP8r{#(<^Ko1Lt?(jT3aHt1u|8$g; z5MNVg!l8%&M3WBkmPFaM->*5^9OD-Q(WHQoS9-(0m7=6mTGQ^Mj^2DZ==?Sq%4~z3 zjAG^ms_vlw^3Rl)JEm%JZ}%SmQJr9wpvvgwaz~i2L3J6z~2vUQREMNf8{}&0oAOZ^G8JiODokOT1x>lhQP!} zH6H81h5xPbD|*g{j|!iAhQx@>B9;IqP8XVIqmgao5k>;7006u&05jHI4<)W|u9eGI zOzF&K6wio`Kx)xAv#K{zRGhYkS#rxcm~V2GNeu@;iNj~lsh4##A3wlqOOa;2HqXoM z;5@>|^N~jPE~)gd#9mmH4Ok!uhyoDQ>xfdFo)|L^>udWB0xkBPbaH@?HD+5R8YzdtAWh z`P<;u2VbFW0STL~Yvzj~z~Z2xaXXRfy&HFbs)zp%?l%Tzfj5`v0q_t5TYCnP{Lmk} z(I#3BdlKHzb|mqTyG2VQ8iV{$iE*8#93R0+M2SN(u7niGL_`27jE^IE7yb3UU-Jj1 z#>;J|qPpYu-NBZTZtAG9ymb*N%Hk)2;L#NPl=&ZPb^!$RH&M!2=LlJ?nB_@5e{#y* ztOnn**ZR(H-#K{S<`Ql1i3soSdjNm|HdNcq-Cl}9d#{RLV9DOlq>{BfmZPibm z)O&h9Xo{ej+#L5FtBuu1I-3r+o&Hc3PYb8VE{+)qevcNHSfycmh$UIG9G&*}#itDj!~2PszM zp;(vxC8zcc!n3G@mX%lod1xqyRzLfs0tg3Ot_(|Vti1WQUDu8rf6ZX0xn9PTy=nZH zXgD&|?KmXYMt!AUeD{vAon50I@lvX1WJ!yu4HOnZ=R=F z(dPeiHPT6MS}%G@AVgJsHoOn=w;@K5G=vHw1cOguSHs%bez^&_RgeJ&O3}Krh4;Bz10uUhr5Fr6T1cU%862Lg+LfbYG(BXG*Jq5`U+9Od z5z7gjKl;vdt2*&$0R&!5OjbuaFb-^$@}27D7!p7OkDi6^%sfR*dTJWG6%)8bx-aB& zn&(_nMidle$I3|G7}^m;1OS#6V)R$(D*re3yMNEoR#|3#(;u~TC#Ov84!GH7JSJk( z{x!bj8C3o7400V0fS<(QLS;dzyHq|vrBJ0(3*;a@V_rf3y9l&1TT;1FMxJbBDE z?_Bcdt5Rudajy0<^T7m(o8&tl)yLqad`5$E@g$bbR0?OpDZ)1}o0dXT=jX)YGpwMI zhLfDN=jg4Lg{BEfWu}pwf=JrSbuW8bu1?Baerepd#-1^Uj8Xh+?$LvPOCaydv%sO~Cy#krSPw`78N&6*k(t;~4ayJdD0E)tU?rsugQFvQEXzgPkWUPGg($Y1Db zY3_+63~PL)TMjv;=P{%}BD3=7AU(;PAi@tPTq~+P&iuu94&l^m{d1QX z`X1Gx5@<8k0dJu5@BB^?xohXG74!h4?t}7bUbgXO?Q{SED=_n^J8rM3L;9y}t%{C6o+7}%J2Pdrml*TI17b(O4t+sx;Z{dkH&E!2~P*Wrplx7<&5)6ws}xF#4`#T zAE{UXK-NJ}Gju77p8}V<+H5QxKlz=v$kyhi2#5-8eO<0{u!^hH+Tukn;vD>b`!U19 z+Vwf^cOK(OMa2G1k;+-wHxk>6A- z)~e5nk3VSvw7P-r?ST;ngsHvF{#xFWnx!hGQKy3u1jqi;B=pAsL%xTH|4!{Al5lG@OFaZSZsqPlQ0F#`4f7cu$A_PsfE9}ZHiJY#cj1U-5G~3yptAbNK zUF7he)VXMFIXD>YFOKy4$S8>c0qRXol5D8+^ELaLt6t@=C&h-|zA&!-ZYLS}TBQWa z|EJk&iiXeir{yK)Go1=>2ykhZ03ZZPcW0EI(Z$kD)AQ>13GRkjTd1sH|A*VyKAg?o z%;U~8c5buU)U$RF?PlRh;D!6iGoAcjdNMdnCh|iGYI-cgV$9 zelN%K+3)c`{>**ssB>{1toJ^|yXI7?Q3!y!*P%k4{gMW8D{fv}3l-OV=izhKr*2si zXw9nvA`y@1eXNLbeE>jT+OOaAcKGlJFvE}txh-_=)KqyKi+z`UzbgHGr3 zBRiWevssRXGv6WjPCi=EVxxqNL~rOeAU@0|wD~(`M=u}R6eY6GyX14Bl|3ZCtox7z z5Kp=!-u%g7ycoWoOx<fBckb%sk84Mh52${BhDxT6m%zjidM z_?eKZgG{90H5)CZNe!(fjxI&LOu|vvtW#-OEN}hD0svI|C8GO|K7<#Nhy3T)*5ijG z+sLCu<8qtHt?lE;uu(=7ZMV#4%-||_!K0NtR);9@$Qi;DTt>uhj{yWAnCe(n#S;vLg^HQ`#`|AkSxI zuVA6(zFe4;aqtDrs;))UD}6+iIczpL{>sZFKF(2 zA0gW74ZiAV|C2S%(p+p(-K#Y;2>e^VjC71HbJ_?Ne7nVs9sZ)ke^kN&1ep<(4{$6k znbHUtG|O8k?3E`f0=apGOM6=DZ|#?ws=a~X)8h9}9uf%v{iQjOTXk(t)fn{3Uz{n@ z_*-2ks|#7Rrg8UuQ~C_#O6gS-M`kmb<#G3B)LnJ=H&Sm}p}d+tdFmY#KRUp88YLXq z#Su(tu_agJr{>u_%1WYL#-kmBFddWnZQB%{THSfyTIlVb*UAO6=XH2_BgS01@#QGZ z^TqvhDB64^#W}u*B8+t7d~y@n_y2}R6V$%PXH?O-x})ddamnA9j-5N6CzZGK*L}Q< zB#=l)NB<$sPnrj`_vHrA=CAGI(ds71_kmwEe;%V}{hL0dF5Z5V{&>Om1N-`kEFwEt zRR=>u#SI<|x5Lk6xAE3G+KjJkfJv6&*C?q4TW`m5YQ??}@hOmMW92Y^GE`oQYvw*L z=f=kWpBas4Gn+_yf{bQ&&l?xpC(&cT$ndfJ(p*5nZCK_TsY%shB(#h7(V-~c{uJPGJJHwlGU?#v4>B8 z0pe+Y5n?{QOLmRn`OyR;(Rr!97TI-T%2nZh84Q0PP`)-=_8rXIn7)KX7sC}ie;!a$ zv(Kn(V=uODGB_n5@GR0vCQlt!{X9T_R+mX!n3fy?3V;RAcZWtu=AcQk%Az0;FQ^bX zu+6MXm2g>XobMrr8B*)BeR1?9FFjE)-B`COLUf{QTYeb2&5oW8j8`v;A2^~}p;A@G zUHH~%zhICAy|S_{S_b-9Q)bkXTow<&m9759o&SR%QZTW;}85= zy4_P~HD=8xyDWe3eQu|d$~<7)p8t?On*&^9MV9l=FYUV!s(YHtd+@v6(}6aUFm|XPT=iz0zoqQ>Qt)08`53V@9k=6dCb(kd^qM-J zsoDw6cQpn#jT(a{CkFmKx>1=)Ha16py8Yq4^bpsij{Iad@oJ{hF7MdSWSC$ljx5l_ zAvhP#L-6>VBr8`RF5?6c{B70VOtW~)n^V&mtCle{bxX@N+dA55Z+?}%S274$LT;N% z7V_)T--Gagrd}-6SgwY0lZ65lIY}ZL!&NP-P<9Kmhzt}w&f`kG9 z1gDXKsJhE+F)QoIn9r*t90&N~Fj5b6e=0lIU#AC0I(&JLdCaZx7z5dg$>DF&ree5v zRNB8jzLcGxNP5X3muT^*y|&M?zqfWx)&4qU1>i_ZO5nkQ02+9`R+g_xIaOCf zzo2Tsf1ftiQ22z47PDz$&f!~bn}oSp;W2Djqd0lAy{!~ty7Yvrd^-aV7WS1z5r4Fk zD_We#Mu$9S4xm8jXaIAatCIDIUdgi$=HJKztFL9ur1~)e5#rAD1l0vqDHC%%*~wO@6<3 zNX&9FxeVpEh23)a!ItZzX2<|B zwU_`91Eq7wLX3a{4yZy}yfj~HnWXq&kSkr87vuX{bq#$Es?)kTHat=Q0kSsVeYDcV zGc*7JCtZ)gPO*5Iuc|h6*+KyXsQWq<(YmiLyAh##&2)BED|AhBo!ts3eCr&C*5N&D zf&lg?=lSql0gp=zYQO0qp8ltIlvo`)5CMC=Y2D0%s3IYYrr<11Rc)X!e+&FKPgeix zs+jE%>!z0N<~}hP`~y_ZQvLP#DUxnhVlkkx@EUvNtrenE&+d|Mw?HM6%b$C@-7<&s zFQ#9lVJM;A@;s&-Dj#O8MaxFhwj;$E*@Fdx4C;Zk*+3u#WvwSmldjrXV#MZu z{9Aj1H7M5ZVD1((ecQIaNqzj)S|P_|ZEsy3uW;d&|MvN2l<&vkKWMFOWV5>I!t?9D z%PI&;hYezvVoAIQIgT0M0_>59w#Jdc7EqTFLlB?$Y0%-eY*HvV{SJJiPgMI=d6X?3rUG(G64jc!o;=kKZ&_K0%%X!sDrmgDWHZ%k-%pm-j8^y{C2AeAC^7au;sAgi zdatpK0RDrK`SQ~)MFZ^x?Xo0IJJI6%vbSuH<(Lfxh_uj*sb7xo!Jrz;@1l`_{!MMx*E-uWCp7^=&Efd4HlnL#`d2mvn!f6D(# zp6xdP!gPc6EBpE`0Vh-dKtP@Dqj#ql=e(D;&Nu=@pd^Cv{990ykgvZ~tp9Sk#NW=t z^UbvYfB`z3bsx`*YiYV=yVig}0G~TcTHC76XaGRx|2TL=4(nCkaLu|P5_zIJM900JPoTUzGEzKH>PflV9t#B(wdS;<6~y{xBYn)Z1?Y}EWaB=03n=g-&W{W3ab+bSXZ)Aa2(mpscE z_KBanMB+4xn$+KWmoeRic*^`Yc8!uBDog~qD}=?*gBf>&^q!1ePR-L4TGx;6_kWLm z7DkjU$o##JT?@}c?A*1`;+1igGIHjL^tv?7JMF(&K>7(2PBO?jS+}Fxs)&}nX0EMJ z@Au54_Z(c*W!>=s0SiGF{=V5KJPdOQ94C8k@UEF}q2eY=7>aeHO8Z2acyU;w{FbP7 zCe)M-`Fo#3bKMuyP}w)$;fuqf+aFy@Y-!|td`@!c0D=I1Imwb18_hPZT^xfdBQjD^ z`gvG}jjW=N=zIler5uyy^GH}}FB^`z<}6&TyPAx0-f~SzX@3>qfFMP3gQT72Uid5a zr8XHV58L0CA*vOn>5li-w7B+dQzxC7P8rQ;jK|*@Ck(?ZR{6v=;yqX|nON;zE$%Up z7`6d_)@3DG7unxN=C%sMN{>z&*w)vQiYa|@&rr6Yt--SLpt2pA(vM*L zrq3EVJMZJo5S2hf00;$>MyMCK9b}${Ydu(@l}#-xhwj+W2#OK$>H%;2zXqzRp4GRU zo(tDN_j9Y9y~yh0sD1U@{CtwmwDQ4@itXJB{H+YUSJvLY8)tN5euj;OG7A>YBfW@u zZpGrFQ?H%I)Vytm$-h08Mb(TDN(|MS>kTap{PmZ5ag5zJLag|dxu+o-5RN_NEkW^V zK5d1WaT^$&=JGPECc`rDWFrl#nCP30q&xbdg`@$Q~3nI{#?C|hR2h>Q*vm4kPI1+ zL_(u703bK6-pUuZlT9g&ct;p<6;2@8^HkeyTzQV{`>9uI=D(j(_kQRB1ds@<*kb=G z8JUh|@-NxonbN#UBzUFc=ono(o$OF?KvZ+4V$mjeCDUF0o@|uloo3O48pL+_RcZR* z0sw#@KoPZnc8yHi>$0KNSDG9KoBbNTL%u8X7|Mb_TWonf>0J@v4nYFZ2T*!Q+5K!wSp$K z%=Hp7qF8Ryi7STa$Elx<+$T7U3TSCv{`Zm(`AK~RG^j_VW8>nf&Q+xBn6X0}vWfYU zPEBQ{8%n*liAruBuG~AnD?MKim7fo{dQddR00DL;!;{q_kBj_(*bFZuGTWXsePXfe z7|O=eZyj7b7U-w=w?7k1Hzsk$=0Ts76tde{A2h{6JGyBJUzNWZf3&AiZgtPD`MP6t z-z>DFXp=BwQe=n(lN5{m_BBx}S1Ud=7Bqx($kAbBJ4v`3l-FLC3eRDT{9)_nBC2Ba z4~l2Js*Z^Bz8)g#ySy=3Z1h5l zym$bCQHs|8Mg4!onupV@0R_rYC!?m)^kEp(pH<*+zXJ@bWU3@NzfSi}+8qqJsR&8j zx&_Uar&t|6=~kp9cSR&qK{5|S8_BDD-c5#A10!VIn(zBz)5>%i;4pGIDsP6aVln$Y za?2H32qX{)A~&xeitwV3^12JdZy`Qw*!d84Niwl^$KUXF+F+?FhI2N>wrK7uv2wS> zLMG4K_L*E2UnlvGV$gCS4pT_5Ztd@#^r{WgyOriK(f_B`q1>S zQBYM%qT-Vo5fVztY*+~0Km-Cr=VdRl{>6oXMiGk7tHbeluFPBFLm(4K@_SE ztw3$lze}4yw#C$LPm2LY&P^pkjZPy^6HalckZfNYXqt*kqy?;8E|u`>Vv>B(*klzw zYhWw@0RSHi@a24v<`)S)p4#`~D&RkdG_71M3|;o+aejns`*XFA4DW7Y%9#zys%VA! z+hj=))N=dG-l6f)ORD;!Kbe}SuPe=56v3aYs4$rqIm;lwk@IsAwI?-J zVgFH;2j}mO4Ix;4X+F)|1#KTI{~H&8v1Wmh9HLsZVDI!?!&v>Pwe!_HHRU84YBIZz zHK~`Nxs;rK8u3Y);kWk6pP%e%pFb5jisII*W>SELLNClL+l@7FlW(ZixK_cx`C+d2 zcUj7|XC18uK-PB6EZN{Q__vIz_oSeGLtOt|Nr!L7Iuov%sYPjHgO#HmBK#DkO3{a_ z*T?1+&&*ru8{H!#t#Pc~;m}cLi4n_--p$M9S0n%ZVt6zcuCKBdYOGVcKX_5Rtz zm!oT2(LxQ}ArUFkMt^HIzJ9y>AeD@H{PLaC03nS54tNNEf9b9NwMz!N_iv}pdUp=v zLvT3oB5U0fgShqUxnMvS8&)G?yS(Jw*hvHde{?xIv^2 Date: Fri, 13 Sep 2024 23:57:13 +0300 Subject: [PATCH 154/227] Add backup test for honeypot task --- tests/python/rest_api/test_tasks.py | 49 ++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 0e43a8f06ec5..582af1c99f9e 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -3363,7 +3363,17 @@ def test_can_export_backup_with_both_api_versions( @pytest.mark.parametrize("mode", ["annotation", "interpolation"]) def test_can_export_backup(self, tasks, mode): - task_id = next(t for t in tasks if t["mode"] == mode)["id"] + task_id = next(t for t in tasks if t["mode"] == mode and not t["validation_mode"])["id"] + task = self.client.tasks.retrieve(task_id) + + filename = self.tmp_dir / f"task_{task.id}_backup.zip" + task.download_backup(filename) + + assert filename.is_file() + assert filename.stat().st_size > 0 + + def test_can_export_backup_for_honeypot_task(self, tasks): + task_id = next(t for t in tasks if t["validation_mode"] == "gt_pool")["id"] task = self.client.tasks.retrieve(task_id) filename = self.tmp_dir / f"task_{task.id}_backup.zip" @@ -3386,8 +3396,12 @@ def test_cannot_export_backup_for_task_without_data(self, tasks): @pytest.mark.parametrize("mode", ["annotation", "interpolation"]) def test_can_import_backup(self, tasks, mode): - task_json = next(t for t in tasks if t["mode"] == mode) - self._test_can_restore_backup_task(task_json["id"]) + task_id = next(t for t in tasks if t["mode"] == mode)["id"] + self._test_can_restore_task_from_backup(task_id) + + def test_can_import_backup_with_honeypot_task(self, tasks): + task_id = next(t for t in tasks if t["validation_mode"] == "gt_pool")["id"] + self._test_can_restore_task_from_backup(task_id) @pytest.mark.parametrize("mode", ["annotation", "interpolation"]) def test_can_import_backup_for_task_in_nondefault_state(self, tasks, mode): @@ -3401,28 +3415,32 @@ def test_can_import_backup_for_task_in_nondefault_state(self, tasks, mode): for j in jobs: j.update({"stage": "validation"}) - self._test_can_restore_backup_task(task_json["id"]) + self._test_can_restore_task_from_backup(task_json["id"]) - def _test_can_restore_backup_task(self, task_id: int): - task = self.client.tasks.retrieve(task_id) + def _test_can_restore_task_from_backup(self, task_id: int): + old_task = self.client.tasks.retrieve(task_id) (_, response) = self.client.api_client.tasks_api.retrieve(task_id) task_json = json.loads(response.data) - filename = self.tmp_dir / f"task_{task.id}_backup.zip" - task.download_backup(filename) + filename = self.tmp_dir / f"task_{old_task.id}_backup.zip" + old_task.download_backup(filename) - restored_task = self.client.tasks.create_from_backup(filename) + new_task = self.client.tasks.create_from_backup(filename) - old_jobs = task.get_jobs() - new_jobs = restored_task.get_jobs() + old_meta = json.loads(old_task.api.retrieve_data_meta(old_task.id)[1].data) + new_meta = json.loads(new_task.api.retrieve_data_meta(new_task.id)[1].data) + assert DeepDiff(old_meta, new_meta, ignore_order=True) == {} + + old_jobs = sorted(old_task.get_jobs(), key=lambda j: (j.start_frame, j.type)) + new_jobs = sorted(new_task.get_jobs(), key=lambda j: (j.start_frame, j.type)) assert len(old_jobs) == len(new_jobs) for old_job, new_job in zip(old_jobs, new_jobs): - assert old_job.status == new_job.status - assert old_job.start_frame == new_job.start_frame - assert old_job.stop_frame == new_job.stop_frame + old_job_meta = json.loads(old_job.api.retrieve_data_meta(old_job.id)[1].data) + new_job_meta = json.loads(new_job.api.retrieve_data_meta(new_job.id)[1].data) + assert DeepDiff(old_job_meta, new_job_meta, ignore_order=True) == {} - (_, response) = self.client.api_client.tasks_api.retrieve(restored_task.id) + (_, response) = self.client.api_client.tasks_api.retrieve(new_task.id) restored_task_json = json.loads(response.data) assert restored_task_json["assignee"] is None @@ -3461,6 +3479,7 @@ def _test_can_restore_backup_task(self, task_id: int): r"root\['target_storage'\]", # should be dropped r"root\['jobs'\]\['completed'\]", # job statuses should be renewed r"root\['jobs'\]\['validation'\]", # job statuses should be renewed + r"root\['status'\]", # task status should be renewed # depends on the actual job configuration, # unlike to what is obtained from the regular task creation, # where the requested number is recorded From b9f6f8be8cefab771805b0ce2f3d83a869ec15e6 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 14 Sep 2024 00:29:19 +0300 Subject: [PATCH 155/227] Fixes --- cvat/apps/dataset_manager/task.py | 4 +++- cvat/apps/engine/task.py | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index d0357a50d4c8..c777f46b0be9 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -891,7 +891,9 @@ def _preprocess_input_annotations_for_gt_pool_task( zip(itertools.repeat('tag'), gt_annotations.tags), zip(itertools.repeat('shape'), gt_annotations.shapes), ): - for placeholder_frame_id in task_validation_frame_groups[gt_annotation["frame"]]: + for placeholder_frame_id in task_validation_frame_groups.get( + gt_annotation["frame"], [] # some GT frames may be unused + ): gt_annotation = faster_deepcopy(gt_annotation) gt_annotation["frame"] = placeholder_frame_id diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 0963c75b750f..eeb31467540c 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1212,10 +1212,10 @@ def _update_status(msg: str) -> None: case models.JobFrameSelectionMethod.MANUAL: known_frame_names = {frame.path: frame.frame for frame in images} unknown_requested_frames = [] - for frame_filename in db_data.validation_layout.frames.all(): - frame_id = known_frame_names.get(frame_filename.path) + for frame_filename in validation_params["frames"]: + frame_id = known_frame_names.get(frame_filename) if frame_id is None: - unknown_requested_frames.append(frame_filename.path) + unknown_requested_frames.append(frame_filename) continue pool_frames.append(frame_id) From 63c15ec3aa00cbd6380af3041707a5ad44f637d1 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 14 Sep 2024 15:50:31 +0300 Subject: [PATCH 156/227] Fix backup restoring for custom jobs without honeypots --- cvat/apps/engine/backup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index c58e74d41db0..82362e9ea3b5 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -797,8 +797,8 @@ def _import_task(self): if job_file_mapping and ( not validation_params or validation_params['mode'] != models.ValidationMode.GT_POOL ): - # It's currently prohibited not allowed to have repeated file names in jobs. - # DataSerializer checks it, but we don't need it for tasks with a GT pool + # It's currently prohibited to have repeated file names in jobs. + # DataSerializer checks for this, but we don't need it for tasks with a GT pool data['job_file_mapping'] = job_file_mapping self._db_task = models.Task.objects.create(**self._manifest, organization_id=self._org_id) @@ -825,7 +825,7 @@ def _import_task(self): data = data_serializer.data data['client_files'] = uploaded_files - if job_file_mapping and ( + if job_file_mapping or ( validation_params and validation_params['mode'] == models.ValidationMode.GT_POOL ): data['job_file_mapping'] = job_file_mapping From 5a7a4191913e96e2dbf9eca3911013bd27344905 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 14 Sep 2024 15:51:07 +0300 Subject: [PATCH 157/227] Fix gt annotation copying on task annotation uploading --- cvat/apps/dataset_manager/task.py | 35 ++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index c777f46b0be9..53a7f9e400da 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -815,11 +815,11 @@ def _patch_data(self, data: Union[AnnotationIR, dict], action: Optional[PatchAct if not isinstance(data, AnnotationIR): data = AnnotationIR(self.db_task.dimension, data) - if action != PatchAction.DELETE and ( + if ( hasattr(self.db_task.data, 'validation_layout') and self.db_task.data.validation_layout.mode == models.ValidationMode.GT_POOL ): - self._preprocess_input_annotations_for_gt_pool_task(data) + self._preprocess_input_annotations_for_gt_pool_task(data, action=action) splitted_data = {} jobs = {} @@ -853,7 +853,7 @@ def create(self, data): self._patch_data(data, PatchAction.CREATE) def _preprocess_input_annotations_for_gt_pool_task( - self, data: Union[AnnotationIR, dict] + self, data: Union[AnnotationIR, dict], *, action: Optional[PatchAction] ) -> AnnotationIR: if not isinstance(data, AnnotationIR): data = AnnotationIR(self.db_task.dimension, data) @@ -868,7 +868,7 @@ def _preprocess_input_annotations_for_gt_pool_task( db_job for db_job in self.db_jobs if db_job.type == models.JobType.GROUND_TRUTH ) - # Copy GT pool annotations into other jobs + # Copy GT pool annotations into other jobs, with replacement of any existing annotations gt_pool_frames = gt_job.segment.frame_set task_validation_frame_groups: dict[int, int] = {} # real_id -> [placeholder_id, ...] task_validation_frame_ids: set[int] = set() @@ -884,6 +884,20 @@ def _preprocess_input_annotations_for_gt_pool_task( assert sorted(gt_pool_frames) == list(range(min(gt_pool_frames), max(gt_pool_frames) + 1)) gt_annotations = data.slice(min(gt_pool_frames), max(gt_pool_frames)) + if action and not ( + gt_annotations.tags or gt_annotations.shapes or gt_annotations.tracks + ): + return + + if not ( + action is None or # put + action == PatchAction.CREATE + ): + # allow validation frame editing only with full task updates + raise ValidationError( + "Annotations on validation frames can only be edited via task import or the GT job" + ) + task_annotation_manager = AnnotationManager(data, dimension=self.db_task.dimension) task_annotation_manager.clear_frames(task_validation_frame_ids) @@ -894,13 +908,18 @@ def _preprocess_input_annotations_for_gt_pool_task( for placeholder_frame_id in task_validation_frame_groups.get( gt_annotation["frame"], [] # some GT frames may be unused ): - gt_annotation = faster_deepcopy(gt_annotation) - gt_annotation["frame"] = placeholder_frame_id + copied_annotation = faster_deepcopy(gt_annotation) + copied_annotation["frame"] = placeholder_frame_id + + for ann in itertools.chain( + [copied_annotation], copied_annotation.get('elements', []) + ): + ann.pop("id", None) if ann_type == 'tag': - data.add_tag(gt_annotation) + data.add_tag(copied_annotation) elif ann_type == 'shape': - data.add_shape(gt_annotation) + data.add_shape(copied_annotation) else: assert False From c297dac1410dc2e23cc80e7989a5d6d3697c9d32 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 14 Sep 2024 15:51:39 +0300 Subject: [PATCH 158/227] Fix validation frames fetching on task creation --- cvat/apps/engine/views.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 750497f28477..b9531bb764cb 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: MIT from abc import ABCMeta, abstractmethod +import itertools import os import os.path as osp import re @@ -1118,6 +1119,12 @@ def _handle_upload_data(request): if optional_field in serializer.validated_data: data[optional_field] = serializer.validated_data[optional_field] + if validation_params := getattr(db_data, 'validation_params', None): + data['validation_params']['frames'] = set(itertools.chain( + data['validation_params'].get('frames', []), + validation_params.frames.values_list('path', flat=True).all() + )) + if ( data['sorting_method'] == models.SortingMethod.PREDEFINED and (uploaded_files := data['client_files']) From 0313ffa2a832f7ed5a71c32985f17ab5964b9e14 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 14 Sep 2024 15:55:08 +0300 Subject: [PATCH 159/227] Refactor tests, add make_sdk_client function --- tests/python/rest_api/test_tasks.py | 88 ++++++++-------------------- tests/python/rest_api/utils.py | 2 + tests/python/shared/fixtures/data.py | 11 ++++ tests/python/shared/utils/config.py | 12 +++- 4 files changed, 47 insertions(+), 66 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 582af1c99f9e..e3c9c63dba85 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -28,7 +28,7 @@ import attrs import numpy as np import pytest -from cvat_sdk import Client, Config, exceptions +from cvat_sdk import exceptions from cvat_sdk.api_client import models from cvat_sdk.api_client.api_client import ApiClient, ApiException, Endpoint from cvat_sdk.core.helpers import get_paginated_collection @@ -42,11 +42,10 @@ import shared.utils.s3 as s3 from shared.fixtures.init import docker_exec_cvat, kube_exec_cvat from shared.utils.config import ( - BASE_URL, - USER_PASS, delete_method, get_method, make_api_client, + make_sdk_client, patch_method, post_method, put_method, @@ -3325,9 +3324,6 @@ def test_work_with_task_containing_non_stable_cloud_storage_files( @pytest.mark.usefixtures("restore_redis_inmem_per_function") class TestTaskBackups: - def _make_client(self) -> Client: - return Client(BASE_URL, config=Config(status_check_period=0.01)) - @pytest.fixture(autouse=True) def setup( self, @@ -3338,11 +3334,10 @@ def setup( ): self.tmp_dir = tmp_path - self.client = self._make_client() self.user = admin_user - with self.client: - self.client.login((self.user, USER_PASS)) + with make_sdk_client(self.user) as client: + self.client = client @pytest.mark.parametrize("api_version", product((1, 2), repeat=2)) @pytest.mark.parametrize( @@ -3440,6 +3435,10 @@ def _test_can_restore_task_from_backup(self, task_id: int): new_job_meta = json.loads(new_job.api.retrieve_data_meta(new_job.id)[1].data) assert DeepDiff(old_job_meta, new_job_meta, ignore_order=True) == {} + old_job_annotations = json.loads(old_job.api.retrieve_annotations(old_job.id)[1].data) + new_job_annotations = json.loads(new_job.api.retrieve_annotations(new_job.id)[1].data) + assert compare_annotations(old_job_annotations, new_job_annotations) == {} + (_, response) = self.client.api_client.tasks_api.retrieve(new_task.id) restored_task_json = json.loads(response.data) @@ -3489,6 +3488,10 @@ def _test_can_restore_task_from_backup(self, task_id: int): == {} ) + old_task_annotations = json.loads(old_task.api.retrieve_annotations(old_task.id)[1].data) + new_task_annotations = json.loads(new_task.api.retrieve_annotations(new_task.id)[1].data) + assert compare_annotations(old_task_annotations, new_task_annotations) == {} + @pytest.mark.usefixtures("restore_db_per_function") class TestWorkWithGtJobs: @@ -3499,17 +3502,7 @@ def test_normal_and_gt_job_annotations_are_not_merged( task = tasks[gt_job["task_id"]] task_jobs = [j for j in jobs if j["task_id"] == task["id"]] - gt_job_source_annotations = annotations["job"][str(gt_job["id"])] - assert ( - gt_job_source_annotations["tags"] - or gt_job_source_annotations["shapes"] - or gt_job_source_annotations["tracks"] - ) - - with Client(BASE_URL) as client: - client.config.status_check_period = 0.01 - client.login((admin_user, USER_PASS)) - + with make_sdk_client(admin_user) as client: for j in task_jobs: if j["type"] != "ground_truth": client.jobs.retrieve(j["id"]).remove_annotations() @@ -3656,18 +3649,14 @@ def test_task_unassigned_cannot_see_task_preview( class TestUnequalJobs: - def _make_client(self) -> Client: - return Client(BASE_URL, config=Config(status_check_period=0.01)) - @pytest.fixture(autouse=True) def setup(self, restore_db_per_function, tmp_path: Path, admin_user: str): self.tmp_dir = tmp_path - self.client = self._make_client() self.user = admin_user - with self.client: - self.client.login((self.user, USER_PASS)) + with make_sdk_client(self.user) as client: + self.client = client @pytest.fixture def fxt_task_with_unequal_jobs(self): @@ -3692,7 +3681,7 @@ def fxt_task_with_unequal_jobs(self): "job_file_mapping": expected_segments, } - return self.client.tasks.create_from_data( + yield self.client.tasks.create_from_data( spec=task_spec, resource_type=ResourceType.LOCAL, resources=[self.tmp_dir / fn for fn in filenames], @@ -3788,18 +3777,7 @@ def test_move_task_from_one_project_to_another_with_attributes(self, task_id, pr response = get_method(user, f"tasks/{task_id}/annotations") assert response.status_code == HTTPStatus.OK - assert ( - DeepDiff( - annotations, - response.json(), - ignore_order=True, - exclude_regex_paths=[ - r"root\['\w+'\]\[\d+\]\['label_id'\]", - r"root\['\w+'\]\[\d+\]\['attributes'\]\[\d+\]\['spec_id'\]", - ], - ) - == {} - ) + assert compare_annotations(annotations, response.json()) == {} @pytest.mark.with_external_services @pytest.mark.parametrize( @@ -3898,19 +3876,15 @@ def test_can_report_correct_completed_jobs_count(tasks_wlc, jobs_wlc, admin_user class TestImportTaskAnnotations: - def _make_client(self) -> Client: - return Client(BASE_URL, config=Config(status_check_period=0.01)) - @pytest.fixture(autouse=True) def setup(self, restore_db_per_function, tmp_path: Path, admin_user: str): self.tmp_dir = tmp_path - self.client = self._make_client() self.user = admin_user self.export_format = "CVAT for images 1.1" self.import_format = "CVAT 1.1" - with self.client: - self.client.login((self.user, USER_PASS)) + with make_sdk_client(self.user) as client: + self.client = client def _check_annotations(self, task_id): with make_api_client(self.user) as api_client: @@ -4097,11 +4071,8 @@ def test_check_import_error_on_wrong_file_structure(self, tasks_with_shapes, for assert b"Dataset must contain a file:" in capture.value.body +@pytest.mark.usefixtures("restore_redis_inmem_per_function") class TestImportWithComplexFilenames: - @staticmethod - def _make_client() -> Client: - return Client(BASE_URL, config=Config(status_check_period=0.01)) - @pytest.fixture( autouse=True, scope="class", @@ -4114,12 +4085,11 @@ def setup_class( cls, restore_db_per_class, tmp_path_factory: pytest.TempPathFactory, admin_user: str ): cls.tmp_dir = tmp_path_factory.mktemp(cls.__class__.__name__) - cls.client = cls._make_client() cls.user = admin_user cls.format_name = "PASCAL VOC 1.1" - with cls.client: - cls.client.login((cls.user, USER_PASS)) + with make_sdk_client(cls.user) as client: + cls.client = client cls._init_tasks() @@ -4289,19 +4259,7 @@ def delete_annotation_and_import_annotations( return original_annotations, imported_annotations def compare_original_and_import_annotations(self, original_annotations, imported_annotations): - assert ( - DeepDiff( - original_annotations, - imported_annotations, - ignore_order=True, - exclude_regex_paths=[ - r"root(\['\w+'\]\[\d+\])+\['id'\]", - r"root(\['\w+'\]\[\d+\])+\['label_id'\]", - r"root(\['\w+'\]\[\d+\])+\['attributes'\]\[\d+\]\['spec_id'\]", - ], - ) - == {} - ) + assert compare_annotations(original_annotations, imported_annotations) == {} @pytest.mark.parametrize("format_name", ["Datumaro 1.0", "COCO 1.0", "PASCAL VOC 1.1"]) def test_export_and_import_tracked_format_with_outside_true(self, format_name): diff --git a/tests/python/rest_api/utils.py b/tests/python/rest_api/utils.py index e3fbc9d1e971..198134879bfa 100644 --- a/tests/python/rest_api/utils.py +++ b/tests/python/rest_api/utils.py @@ -580,11 +580,13 @@ def _exclude_cb(obj, path): a, b, ignore_order=True, + significant_digits=2, # annotations are stored with 2 decimal digit precision exclude_obj_callback=_exclude_cb, exclude_regex_paths=[ r"root\['version|updated_date'\]", r"root(\['\w+'\]\[\d+\])+\['id'\]", r"root(\['\w+'\]\[\d+\])+\['label_id'\]", r"root(\['\w+'\]\[\d+\])+\['attributes'\]\[\d+\]\['spec_id'\]", + r"root(\['\w+'\]\[\d+\])+\['source'\]", ], ) diff --git a/tests/python/shared/fixtures/data.py b/tests/python/shared/fixtures/data.py index 6c328336dd13..a3ca9f09e5f9 100644 --- a/tests/python/shared/fixtures/data.py +++ b/tests/python/shared/fixtures/data.py @@ -516,3 +516,14 @@ def regular_lonely_user(users): if user["username"] == "lonely_user": return user["username"] raise Exception("Can't find the lonely user in the test DB") + + +@pytest.fixture(scope="session") +def job_has_annotations(annotations) -> bool: + def check_has_annotations(job_id: int) -> bool: + job_annotations = annotations["job"][str(job_id)] + return bool( + job_annotations["tags"] or job_annotations["shapes"] or job_annotations["tracks"] + ) + + return check_has_annotations diff --git a/tests/python/shared/utils/config.py b/tests/python/shared/utils/config.py index 2c9a06a22d24..f313334c797d 100644 --- a/tests/python/shared/utils/config.py +++ b/tests/python/shared/utils/config.py @@ -1,11 +1,14 @@ -# Copyright (C) 2022 CVAT.ai Corporation +# Copyright (C) 2022-2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT +from contextlib import contextmanager from pathlib import Path +from typing import Generator import requests from cvat_sdk.api_client import ApiClient, Configuration +from cvat_sdk.core.client import Client, Config ROOT_DIR = next(dir.parent for dir in Path(__file__).parents if dir.name == "utils") ASSETS_DIR = (ROOT_DIR / "assets").resolve() @@ -71,3 +74,10 @@ def make_api_client(user: str, *, password: str = None) -> ApiClient: return ApiClient( configuration=Configuration(host=BASE_URL, username=user, password=password or USER_PASS) ) + + +@contextmanager +def make_sdk_client(user: str, *, password: str = None) -> Generator[Client, None, None]: + with Client(BASE_URL, config=Config(status_check_period=0.01)) as client: + client.login((user, password or USER_PASS)) + yield client From 814a5dbf564af8e824f3848c3a67f7566677c65b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Sat, 14 Sep 2024 15:56:23 +0300 Subject: [PATCH 160/227] Add tests for annotation updating in honeypot tasks --- tests/python/rest_api/test_tasks.py | 159 ++++++++++++++++++++-------- 1 file changed, 117 insertions(+), 42 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index e3c9c63dba85..826aaee80c32 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -556,6 +556,7 @@ def test_member_update_task_annotation( ): users = find_users(role=role, org=org) tasks = tasks_by_org[org] + tasks = [t for t in tasks if t["validation_mode"] != "gt_pool"] username, tid = find_task_staff_user(tasks, users, task_staff) data = request_data(tid) @@ -570,6 +571,57 @@ def test_member_update_task_annotation( self._test_check_response(is_allow, response, data) + def test_cannot_update_validation_frames_in_honeypot_task( + self, + admin_user, + tasks, + request_data, + ): + task_id = next(t for t in tasks if t["validation_mode"] == "gt_pool" and t["size"] > 0)[ + "id" + ] + + data = request_data(task_id) + with make_api_client(admin_user) as api_client: + (_, response) = api_client.tasks_api.partial_update_annotations( + id=task_id, + action="update", + patched_labeled_data_request=deepcopy(data), + _parse_response=False, + _check_status=False, + ) + + assert response.status == HTTPStatus.BAD_REQUEST + assert b"can only be edited via task import or the GT job" in response.data + + def test_can_update_honeypot_frames_in_honeypot_task( + self, + admin_user, + tasks, + jobs, + request_data, + ): + task_id = next(t for t in tasks if t["validation_mode"] == "gt_pool" and t["size"] > 0)[ + "id" + ] + gt_job = next(j for j in jobs if j["task_id"] == task_id and j["type"] == "ground_truth") + + validation_frames = range(gt_job["start_frame"], gt_job["stop_frame"] + 1) + data = request_data(task_id) + data["tags"] = [a for a in data["tags"] if a["frame"] not in validation_frames] + data["shapes"] = [a for a in data["shapes"] if a["frame"] not in validation_frames] + data["tracks"] = [] # tracks cannot be used in honeypot tasks + with make_api_client(admin_user) as api_client: + (_, response) = api_client.tasks_api.partial_update_annotations( + id=task_id, + action="update", + patched_labeled_data_request=deepcopy(data), + _parse_response=False, + _check_status=False, + ) + + self._test_check_response(True, response, data) + def test_remove_first_keyframe(self): endpoint = "tasks/8/annotations" shapes0 = [ @@ -3412,6 +3464,19 @@ def test_can_import_backup_for_task_in_nondefault_state(self, tasks, mode): self._test_can_restore_task_from_backup(task_json["id"]) + def test_can_import_backup_with_gt_job(self, tasks, jobs, job_has_annotations): + gt_job = next( + j + for j in jobs + if j["type"] == "ground_truth" + if job_has_annotations(j["id"]) + if tasks[j["task_id"]]["validation_mode"] == "gt" + if tasks[j["task_id"]]["size"] + ) + task = tasks[gt_job["task_id"]] + + self._test_can_restore_task_from_backup(task["id"]) + def _test_can_restore_task_from_backup(self, task_id: int): old_task = self.client.tasks.retrieve(task_id) (_, response) = self.client.api_client.tasks_api.retrieve(task_id) @@ -3495,10 +3560,17 @@ def _test_can_restore_task_from_backup(self, task_id: int): @pytest.mark.usefixtures("restore_db_per_function") class TestWorkWithGtJobs: - def test_normal_and_gt_job_annotations_are_not_merged( - self, tmp_path, admin_user, tasks, jobs, annotations + def test_gt_job_annotations_are_not_present_on_task_annotation_export_with_normal_gt_job( + self, tmp_path, admin_user, tasks, jobs, job_has_annotations ): - gt_job = next(j for j in jobs if j["type"] == "ground_truth") + gt_job = next( + j + for j in jobs + if j["type"] == "ground_truth" + if job_has_annotations(j["id"]) + if tasks[j["task_id"]]["validation_mode"] == "gt" + if tasks[j["task_id"]]["size"] + ) task = tasks[gt_job["task_id"]] task_jobs = [j for j in jobs if j["task_id"] == task["id"]] @@ -3532,57 +3604,60 @@ def test_normal_and_gt_job_annotations_are_not_merged( assert not annotation_source.shapes assert not annotation_source.tracks - def test_can_backup_task_with_gt_jobs(self, tmp_path, admin_user, tasks, jobs, annotations): + def test_gt_job_annotations_are_present_on_task_annotation_export_in_honeypot_task( + self, tmp_path, admin_user, tasks, jobs, job_has_annotations + ): gt_job = next( j for j in jobs - if j["type"] == "ground_truth" and tasks[j["task_id"]]["jobs"]["count"] == 2 + if j["type"] == "ground_truth" + if job_has_annotations(j["id"]) + if tasks[j["task_id"]]["validation_mode"] == "gt_pool" + if tasks[j["task_id"]]["size"] ) task = tasks[gt_job["task_id"]] - annotation_job = next( - j for j in jobs if j["task_id"] == task["id"] and j["type"] == "annotation" - ) - - gt_job_source_annotations = annotations["job"][str(gt_job["id"])] - assert ( - gt_job_source_annotations["tags"] - or gt_job_source_annotations["shapes"] - or gt_job_source_annotations["tracks"] - ) + task_jobs = [j for j in jobs if j["task_id"] == task["id"]] + validation_frames = range(gt_job["start_frame"], gt_job["stop_frame"] + 1) - annotation_job_source_annotations = annotations["job"][str(annotation_job["id"])] + with make_sdk_client(admin_user) as client: + for j in task_jobs: + if j["type"] != "ground_truth": + client.jobs.retrieve(j["id"]).remove_annotations() - with Client(BASE_URL) as client: - client.config.status_check_period = 0.01 - client.login((admin_user, USER_PASS)) + task_obj = client.tasks.retrieve(task["id"]) + task_raw_annotations = json.loads(task_obj.api.retrieve_annotations(task["id"])[1].data) - backup_file: Path = tmp_path / "dataset.zip" - client.tasks.retrieve(task["id"]).download_backup(backup_file) + # It's quite hard to parse the dataset files, just import the data back instead + dataset_format = "CVAT for images 1.1" - new_task = client.tasks.create_from_backup(backup_file) - updated_job_annotations = { - j.type: json.loads(j.api.retrieve_annotations(j.id)[1].data) - for j in new_task.get_jobs() - } + dataset_file = tmp_path / "dataset.zip" + task_obj.export_dataset(dataset_format, dataset_file, include_images=True) + task_obj.import_annotations("CVAT 1.1", dataset_file) + task_dataset_file_annotations = json.loads( + task_obj.api.retrieve_annotations(task["id"])[1].data + ) - for job_type, source_annotations in { - gt_job["type"]: gt_job_source_annotations, - annotation_job["type"]: annotation_job_source_annotations, - }.items(): - assert ( - DeepDiff( - source_annotations, - updated_job_annotations[job_type], - ignore_order=True, - exclude_regex_paths=[ - r"root(\['\w+'\]\[\d+\])+\['id'\]", - r"root(\['\w+'\]\[\d+\])+\['label_id'\]", - r"root(\['\w+'\]\[\d+\])+\['attributes'\]\[\d+\]\['spec_id'\]", - ], - ) - == {} + annotations_file = tmp_path / "annotations.zip" + task_obj.export_dataset(dataset_format, annotations_file, include_images=False) + task_obj.import_annotations("CVAT 1.1", annotations_file) + task_annotations_file_annotations = json.loads( + task_obj.api.retrieve_annotations(task["id"])[1].data ) + # there will be other annotations after uploading into a honeypot task, + # we need to compare only the validation frames in this test + for anns in [ + task_raw_annotations, + task_dataset_file_annotations, + task_annotations_file_annotations, + ]: + anns["tags"] = [t for t in anns["tags"] if t["frame"] in validation_frames] + anns["shapes"] = [t for t in anns["shapes"] if t["frame"] in validation_frames] + + assert task_raw_annotations["tags"] or task_raw_annotations["shapes"] + assert compare_annotations(task_raw_annotations, task_dataset_file_annotations) == {} + assert compare_annotations(task_raw_annotations, task_annotations_file_annotations) == {} + @pytest.mark.usefixtures("restore_db_per_class") class TestGetTaskPreview: From 3cdc4dc1dac23a7424e9bc5d51661fcd0898fd74 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 16 Sep 2024 13:41:41 +0300 Subject: [PATCH 161/227] Move env variable into docker-compose.yml --- docker-compose.yml | 1 + .../docker-compose.configurable_static_cache.yml | 16 ---------------- tests/python/shared/fixtures/init.py | 1 - 3 files changed, 1 insertion(+), 17 deletions(-) delete mode 100644 tests/docker-compose.configurable_static_cache.yml diff --git a/docker-compose.yml b/docker-compose.yml index 051bd0bfd8cf..569e163e9fe5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ x-backend-env: &backend-env CVAT_REDIS_ONDISK_HOST: cvat_redis_ondisk CVAT_REDIS_ONDISK_PORT: 6666 CVAT_LOG_IMPORT_ERRORS: 'true' + CVAT_ALLOW_STATIC_CACHE: '${CVAT_ALLOW_STATIC_CACHE:-no}' DJANGO_LOG_SERVER_HOST: vector DJANGO_LOG_SERVER_PORT: 80 no_proxy: clickhouse,grafana,vector,nuclio,opa,${no_proxy:-} diff --git a/tests/docker-compose.configurable_static_cache.yml b/tests/docker-compose.configurable_static_cache.yml deleted file mode 100644 index 5afa43470803..000000000000 --- a/tests/docker-compose.configurable_static_cache.yml +++ /dev/null @@ -1,16 +0,0 @@ -services: - cvat_server: - environment: - CVAT_ALLOW_STATIC_CACHE: '${CVAT_ALLOW_STATIC_CACHE:-no}' - - cvat_worker_import: - environment: - CVAT_ALLOW_STATIC_CACHE: '${CVAT_ALLOW_STATIC_CACHE:-no}' - - cvat_worker_export: - environment: - CVAT_ALLOW_STATIC_CACHE: '${CVAT_ALLOW_STATIC_CACHE:-no}' - - cvat_worker_annotation: - environment: - CVAT_ALLOW_STATIC_CACHE: '${CVAT_ALLOW_STATIC_CACHE:-no}' diff --git a/tests/python/shared/fixtures/init.py b/tests/python/shared/fixtures/init.py index 99f1f02f8e0b..4a17454617d0 100644 --- a/tests/python/shared/fixtures/init.py +++ b/tests/python/shared/fixtures/init.py @@ -31,7 +31,6 @@ "tests/docker-compose.file_share.yml", "tests/docker-compose.minio.yml", "tests/docker-compose.test_servers.yml", - "tests/docker-compose.configurable_static_cache.yml", ] From edaa8d0e8d3069b78b96e9fc55e4e7e91fa2d52c Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 16 Sep 2024 17:44:35 +0300 Subject: [PATCH 162/227] Add test for import and export of annotations in honeypot tasks --- tests/python/rest_api/test_tasks.py | 70 ++++++++++++++++++++++++++--- tests/python/rest_api/utils.py | 4 ++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 826aaee80c32..1cc9e1e361e3 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -9,6 +9,7 @@ import math import os import os.path as osp +import re import zipfile from abc import ABCMeta, abstractmethod from collections import Counter @@ -64,6 +65,7 @@ create_task, export_task_backup, export_task_dataset, + parse_frame_step, wait_until_task_is_created, ) @@ -2424,7 +2426,7 @@ def test_can_create_task_with_gt_job_from_video( assert task.size == resulting_task_size assert task_meta.size == resulting_task_size - frame_step = int((gt_job_metas[0].frame_filter or "step=1").split("=")[1]) + frame_step = parse_frame_step(gt_job_metas[0].frame_filter) validation_frames = [ abs_frame_id for abs_frame_id in range( @@ -3560,7 +3562,7 @@ def _test_can_restore_task_from_backup(self, task_id: int): @pytest.mark.usefixtures("restore_db_per_function") class TestWorkWithGtJobs: - def test_gt_job_annotations_are_not_present_on_task_annotation_export_with_normal_gt_job( + def test_gt_job_annotations_are_not_present_in_task_annotation_export_with_normal_gt_job( self, tmp_path, admin_user, tasks, jobs, job_has_annotations ): gt_job = next( @@ -3604,9 +3606,10 @@ def test_gt_job_annotations_are_not_present_on_task_annotation_export_with_norma assert not annotation_source.shapes assert not annotation_source.tracks - def test_gt_job_annotations_are_present_on_task_annotation_export_in_honeypot_task( - self, tmp_path, admin_user, tasks, jobs, job_has_annotations - ): + @fixture + def fxt_task_with_honeypots( + self, tasks, jobs, job_has_annotations + ) -> Generator[Dict[str, Any], None, None]: gt_job = next( j for j in jobs @@ -3615,9 +3618,13 @@ def test_gt_job_annotations_are_present_on_task_annotation_export_in_honeypot_ta if tasks[j["task_id"]]["validation_mode"] == "gt_pool" if tasks[j["task_id"]]["size"] ) - task = tasks[gt_job["task_id"]] + yield tasks[gt_job["task_id"]], gt_job + + @parametrize("task, gt_job", [fixture_ref(fxt_task_with_honeypots)]) + def test_gt_job_annotations_are_present_in_task_annotation_export_in_task_with_honeypots( + self, tmp_path, admin_user, jobs, task, gt_job + ): task_jobs = [j for j in jobs if j["task_id"] == task["id"]] - validation_frames = range(gt_job["start_frame"], gt_job["stop_frame"] + 1) with make_sdk_client(admin_user) as client: for j in task_jobs: @@ -3646,6 +3653,7 @@ def test_gt_job_annotations_are_present_on_task_annotation_export_in_honeypot_ta # there will be other annotations after uploading into a honeypot task, # we need to compare only the validation frames in this test + validation_frames = range(gt_job["start_frame"], gt_job["stop_frame"] + 1) for anns in [ task_raw_annotations, task_dataset_file_annotations, @@ -3655,9 +3663,57 @@ def test_gt_job_annotations_are_present_on_task_annotation_export_in_honeypot_ta anns["shapes"] = [t for t in anns["shapes"] if t["frame"] in validation_frames] assert task_raw_annotations["tags"] or task_raw_annotations["shapes"] + assert not task_raw_annotations["tracks"] # tracks are prohibited in such tasks assert compare_annotations(task_raw_annotations, task_dataset_file_annotations) == {} assert compare_annotations(task_raw_annotations, task_annotations_file_annotations) == {} + @parametrize("task, gt_job", [fixture_ref(fxt_task_with_honeypots)]) + @pytest.mark.parametrize("dataset_format", ["CVAT for images 1.1", "Datumaro 1.0"]) + def test_placeholder_frames_are_not_present_in_task_annotation_export_in_task_with_honeypots( + self, tmp_path, admin_user, jobs, task, gt_job, dataset_format + ): + task_jobs = [j for j in jobs if j["task_id"] == task["id"]] + + with make_sdk_client(admin_user) as client: + for j in task_jobs: + if j["type"] != "ground_truth": + client.jobs.retrieve(j["id"]).remove_annotations() + + task_obj = client.tasks.retrieve(task["id"]) + + dataset_file = tmp_path / "dataset.zip" + task_obj.export_dataset(dataset_format, dataset_file, include_images=True) + + task_meta = task_obj.get_meta() + + task_frame_names = [frame.name for frame in task_meta.frames] + validation_frame_ids = range(gt_job["start_frame"], gt_job["stop_frame"] + 1) + validation_frame_names = [task_frame_names[i] for i in validation_frame_ids] + + frame_step = parse_frame_step(task_meta.frame_filter) + expected_frames = [ + (task_meta.start_frame + frame * frame_step, name) + for frame, name in enumerate(task_frame_names) + if frame in validation_frame_ids or name not in validation_frame_names + ] + + with zipfile.ZipFile(dataset_file, "r") as archive: + if dataset_format == "CVAT for images 1.1": + annotations = archive.read("annotations.xml").decode() + matches = re.findall(r' int: + return int((frame_filter or "step=1").split("=")[1]) From 6deaf8e7cd54e35245091e0c3340efda6c98377b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 16 Sep 2024 17:55:24 +0300 Subject: [PATCH 163/227] Update tasks with GT jobs in tests assets --- tests/python/shared/assets/annotations.json | 312 +++++++++---------- tests/python/shared/assets/cvat_db/data.json | 20 ++ tests/python/shared/assets/tasks.json | 4 +- 3 files changed, 178 insertions(+), 158 deletions(-) diff --git a/tests/python/shared/assets/annotations.json b/tests/python/shared/assets/annotations.json index 837686e17c77..fa7ba532e28f 100644 --- a/tests/python/shared/assets/annotations.json +++ b/tests/python/shared/assets/annotations.json @@ -8156,162 +8156,6 @@ }, "26": { "shapes": [ - { - "attributes": [ - { - "spec_id": 15, - "value": "gt frame1 n1" - } - ], - "elements": [], - "frame": 23, - "group": 0, - "id": 169, - "label_id": 75, - "occluded": false, - "outside": false, - "points": [ - 17.650000000003274, - 11.30000000000291, - 30.55000000000291, - 21.700000000002547 - ], - "rotation": 0.0, - "source": "Ground truth", - "type": "rectangle", - "z_order": 0 - }, - { - "attributes": [ - { - "spec_id": 15, - "value": "gt frame2 n2" - } - ], - "elements": [], - "frame": 24, - "group": 0, - "id": 170, - "label_id": 75, - "occluded": false, - "outside": false, - "points": [ - 18.850000000002183, - 12.000000000001819, - 25.850000000002183, - 19.50000000000182 - ], - "rotation": 0.0, - "source": "Ground truth", - "type": "rectangle", - "z_order": 0 - }, - { - "attributes": [ - { - "spec_id": 15, - "value": "gt frame3 n3" - } - ], - "elements": [], - "frame": 24, - "group": 0, - "id": 171, - "label_id": 75, - "occluded": false, - "outside": false, - "points": [ - 26.150000000003274, - 25.00000000000182, - 34.150000000003274, - 34.50000000000182 - ], - "rotation": 0.0, - "source": "Ground truth", - "type": "rectangle", - "z_order": 0 - }, - { - "attributes": [ - { - "spec_id": 15, - "value": "gt frame3 n1" - } - ], - "elements": [], - "frame": 25, - "group": 0, - "id": 172, - "label_id": 75, - "occluded": false, - "outside": false, - "points": [ - 24.600000000002183, - 11.500000000001819, - 37.10000000000218, - 18.700000000002547 - ], - "rotation": 0.0, - "source": "Ground truth", - "type": "rectangle", - "z_order": 0 - }, - { - "attributes": [ - { - "spec_id": 15, - "value": "gt frame5 n1" - } - ], - "elements": [], - "frame": 27, - "group": 0, - "id": 175, - "label_id": 75, - "occluded": false, - "outside": false, - "points": [ - 17.863216443472993, - 36.43614886308387, - 41.266725327279346, - 42.765472201610464 - ], - "rotation": 0.0, - "source": "Ground truth", - "type": "rectangle", - "z_order": 0 - }, - { - "attributes": [ - { - "spec_id": 15, - "value": "gt frame5 n2" - } - ], - "elements": [], - "frame": 27, - "group": 0, - "id": 176, - "label_id": 75, - "occluded": false, - "outside": false, - "points": [ - 34.349609375, - 52.806640625, - 27.086274131672326, - 63.1830161588623, - 40.229131337355284, - 67.44868033965395, - 48.87574792004307, - 59.03264019917333, - 45.53238950807099, - 53.3835173651496 - ], - "rotation": 0.0, - "source": "Ground truth", - "type": "polygon", - "z_order": 0 - }, { "attributes": [ { @@ -8729,6 +8573,162 @@ "source": "manual", "type": "rectangle", "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 15, + "value": "gt frame1 n1" + } + ], + "elements": [], + "frame": 23, + "group": 0, + "id": 169, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 17.650000000003274, + 11.30000000000291, + 30.55000000000291, + 21.700000000002547 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 15, + "value": "gt frame2 n2" + } + ], + "elements": [], + "frame": 24, + "group": 0, + "id": 170, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 18.850000000002183, + 12.000000000001819, + 25.850000000002183, + 19.50000000000182 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 15, + "value": "gt frame3 n3" + } + ], + "elements": [], + "frame": 24, + "group": 0, + "id": 171, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 26.150000000003274, + 25.00000000000182, + 34.150000000003274, + 34.50000000000182 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 15, + "value": "gt frame3 n1" + } + ], + "elements": [], + "frame": 25, + "group": 0, + "id": 172, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 24.600000000002183, + 11.500000000001819, + 37.10000000000218, + 18.700000000002547 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 15, + "value": "gt frame5 n1" + } + ], + "elements": [], + "frame": 27, + "group": 0, + "id": 175, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 17.863216443472993, + 36.43614886308387, + 41.266725327279346, + 42.765472201610464 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 15, + "value": "gt frame5 n2" + } + ], + "elements": [], + "frame": 27, + "group": 0, + "id": 176, + "label_id": 75, + "occluded": false, + "outside": false, + "points": [ + 34.349609375, + 52.806640625, + 27.086274131672326, + 63.1830161588623, + 40.229131337355284, + 67.44868033965395, + 48.87574792004307, + 59.03264019917333, + 45.53238950807099, + 53.3835173651496 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "polygon", + "z_order": 0 } ], "tags": [], diff --git a/tests/python/shared/assets/cvat_db/data.json b/tests/python/shared/assets/cvat_db/data.json index ca4271a37dd9..f1530849c43e 100644 --- a/tests/python/shared/assets/cvat_db/data.json +++ b/tests/python/shared/assets/cvat_db/data.json @@ -1198,6 +1198,26 @@ "frames_per_job_count": 3 } }, +{ + "model": "engine.validationlayout", + "pk": 5, + "fields": { + "task_data": 21, + "mode": "gt", + "frames": "0,1,2", + "frames_per_job_count": null + } +}, +{ + "model": "engine.validationlayout", + "pk": 6, + "fields": { + "task_data": 22, + "mode": "gt", + "frames": "4,5,7", + "frames_per_job_count": null + } +}, { "model": "engine.data", "pk": 2, diff --git a/tests/python/shared/assets/tasks.json b/tests/python/shared/assets/tasks.json index cf8b15c43698..4d029c729021 100644 --- a/tests/python/shared/assets/tasks.json +++ b/tests/python/shared/assets/tasks.json @@ -193,7 +193,7 @@ "target_storage": null, "updated_date": "2024-03-21T20:50:05.947000Z", "url": "http://localhost:8080/api/tasks/23", - "validation_mode": null + "validation_mode": "gt" }, { "assignee": null, @@ -237,7 +237,7 @@ "target_storage": null, "updated_date": "2023-11-24T15:23:30.045000Z", "url": "http://localhost:8080/api/tasks/22", - "validation_mode": null + "validation_mode": "gt" }, { "assignee": null, From e6c4cdbd508aab8b5f12c402de54a54475e39f10 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 16 Sep 2024 18:25:33 +0300 Subject: [PATCH 164/227] Fix backup restoring --- cvat/apps/engine/backup.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index 82362e9ea3b5..77cd32c82374 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -613,6 +613,8 @@ def __init__(self, file, user_id, org_id=None, project_id=None, subdir=None, lab super().__init__(logger=slogger.glob) self._file = file self._subdir = subdir + "Task subdirectory with the separator included, e.g. task_0/" + self._user_id = user_id self._org_id = org_id self._manifest, self._annotations, self._annotation_guide, self._assets = self._read_meta() @@ -706,34 +708,32 @@ def _copy_input_files( input_data_dirname = self.DATA_DIRNAME output_data_path = self._db_task.data.get_upload_dirname() uploaded_files = [] - for fn in input_archive.namelist(): - if fn.endswith(os.path.sep) or ( - self._subdir and not fn.startswith(self._subdir + os.path.sep) - ): + for file_path in input_archive.namelist(): + if file_path.endswith('/') or self._subdir and not file_path.startswith(self._subdir): continue - fn = os.path.relpath(fn, self._subdir) - if excluded_filenames and fn in excluded_filenames: + file_name = os.path.relpath(file_path, self._subdir) + if excluded_filenames and file_name in excluded_filenames: continue - if fn.startswith(input_data_dirname + os.path.sep): + if file_name.startswith(input_data_dirname + '/'): target_file = os.path.join( - output_data_path, os.path.relpath(fn, input_data_dirname) + output_data_path, os.path.relpath(file_name, input_data_dirname) ) self._prepare_dirs(target_file) with open(target_file, "wb") as out: - out.write(input_archive.read(fn)) + out.write(input_archive.read(file_path)) - uploaded_files.append(os.path.relpath(fn, input_data_dirname)) - elif fn.startswith(input_task_dirname + os.path.sep): + uploaded_files.append(os.path.relpath(file_name, input_data_dirname)) + elif file_name.startswith(input_task_dirname + '/'): target_file = os.path.join( - output_task_path, os.path.relpath(fn, input_task_dirname) + output_task_path, os.path.relpath(file_name, input_task_dirname) ) self._prepare_dirs(target_file) with open(target_file, "wb") as out: - out.write(input_archive.read(fn)) + out.write(input_archive.read(file_path)) return uploaded_files From b8a25cf3e99ceecdaf381c1f78e6a957b8c20bfe Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 16 Sep 2024 20:22:46 +0300 Subject: [PATCH 165/227] Revert unnecessary backup file copying change --- cvat/apps/engine/backup.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index 77cd32c82374..bed2604b98cb 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -362,23 +362,11 @@ def _write_data(self, zip_object, target_dir=None): target_data_dir = os.path.join(target_dir, self.DATA_DIRNAME) if target_dir else self.DATA_DIRNAME if self._db_data.storage == StorageChoice.LOCAL: data_dir = self._db_data.get_upload_dirname() - if hasattr(self._db_data, 'video'): - media_files = (os.path.join(data_dir, self._db_data.video.path), ) - else: - media_files = ( - os.path.join(data_dir, im.path) - for im in self._db_data.images.exclude(is_placeholder=True).all() - ) - - data_manifest_path = self._db_data.get_manifest_path() - if os.path.isfile(data_manifest_path): - media_files = itertools.chain(media_files, [self._db_data.get_manifest_path()]) - - self._write_files( + self._write_directory( source_dir=self._db_data.get_upload_dirname(), zip_object=zip_object, target_dir=target_data_dir, - files=media_files, + exclude_files=[self.MEDIA_MANIFEST_INDEX_FILENAME] ) elif self._db_data.storage == StorageChoice.SHARE: data_dir = settings.SHARE_ROOT From 28a4a61e31020e9ac9a967650a199202927375a6 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 16 Sep 2024 20:23:21 +0300 Subject: [PATCH 166/227] Fix lambda tests, use relative ids in manual gt job creation --- cvat/apps/engine/serializers.py | 6 +++- cvat/apps/engine/task.py | 4 +-- cvat/apps/lambda_manager/tests/test_lambda.py | 33 +++++-------------- 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index b7d50ea2deed..c7f60a5ede40 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -26,6 +26,7 @@ from django.utils import timezone 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 import field_validation, models from cvat.apps.engine.cloud_provider import get_cloud_storage_instance, Credentials, Status @@ -858,9 +859,12 @@ def create(self, validated_data): if invalid_ids: raise serializers.ValidationError( "The following frames do not exist in the task: {}".format( - format_list(invalid_ids) + format_list(tuple(map(str, invalid_ids))) ) ) + + task_frame_provider = TaskFrameProvider(task) + frames = list(map(task_frame_provider.get_abs_frame_number, frames)) else: raise serializers.ValidationError( f"Unexpected frame selection method '{frame_selection_method}'" diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index eeb31467540c..8f2b68f3eefa 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -37,9 +37,9 @@ ValidateDimension, ZipChunkWriter, ZipCompressedChunkWriter, get_mime, sort ) from cvat.apps.engine.models import RequestAction, RequestTarget -from cvat.apps.engine.serializers import ValidationParamsSerializer from cvat.apps.engine.utils import ( - av_scan_paths, format_list,get_rq_job_meta, define_dependent_job, get_rq_lock_by_user, preload_images + av_scan_paths, format_list,get_rq_job_meta, + define_dependent_job, get_rq_lock_by_user, preload_images ) 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/lambda_manager/tests/test_lambda.py b/cvat/apps/lambda_manager/tests/test_lambda.py index e49b93e24f12..57c74cf2c529 100644 --- a/cvat/apps/lambda_manager/tests/test_lambda.py +++ b/cvat/apps/lambda_manager/tests/test_lambda.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: MIT -from collections import OrderedDict +from collections import Counter, OrderedDict from itertools import groupby from typing import Dict, Optional from unittest import mock, skip @@ -1300,9 +1300,7 @@ def test_can_run_offline_detector_function_on_whole_gt_job(self): "type": "ground_truth", "task_id": self.task["id"], "frame_selection_method": "manual", - "frames": [ - self.start_frame + frame * self.frame_step for frame in requested_frame_range - ], + "frames": list(requested_frame_range), }) self.assertEqual(response.status_code, status.HTTP_201_CREATED) job = response.json() @@ -1319,13 +1317,8 @@ def test_can_run_offline_detector_function_on_whole_gt_job(self): self.assertEqual(len(annotations["tracks"]), 0) self.assertEqual( - { - frame: 1 for frame in requested_frame_range - }, - { - frame: len(list(group)) - for frame, group in groupby(annotations["shapes"], key=lambda a: a["frame"]) - } + { frame: 1 for frame in requested_frame_range }, + Counter(a["frame"] for a in annotations["shapes"]) ) response = self._get_request(f'/api/tasks/{self.task["id"]}/annotations', self.admin) @@ -1339,9 +1332,7 @@ def test_can_run_offline_reid_function_on_whole_gt_job(self): "type": "ground_truth", "task_id": self.task["id"], "frame_selection_method": "manual", - "frames": [ - self.start_frame + frame * self.frame_step for frame in requested_frame_range - ], + "frames": list(requested_frame_range), }) self.assertEqual(response.status_code, status.HTTP_201_CREATED) job = response.json() @@ -1411,10 +1402,7 @@ def test_offline_function_run_on_task_does_not_affect_gt_job(self): "type": "ground_truth", "task_id": self.task["id"], "frame_selection_method": "manual", - "frames": [ - self.start_frame + frame * self.frame_step - for frame in self.task_rel_frame_range[::3] - ], + "frames": list(self.task_rel_frame_range[::3]), }) self.assertEqual(response.status_code, status.HTTP_201_CREATED) job = response.json() @@ -1431,13 +1419,8 @@ def test_offline_function_run_on_task_does_not_affect_gt_job(self): requested_frame_range = self.task_rel_frame_range self.assertEqual( - { - frame: 1 for frame in requested_frame_range - }, - { - frame: len(list(group)) - for frame, group in groupby(annotations["shapes"], key=lambda a: a["frame"]) - } + { frame: 1 for frame in requested_frame_range }, + Counter(a["frame"] for a in annotations["shapes"]) ) response = self._get_request(f'/api/jobs/{job["id"]}/annotations', self.admin) From bc5ed39a1960a6e3699d8e026e0b8252dd65ce93 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 17 Sep 2024 12:40:43 +0300 Subject: [PATCH 167/227] Fix invalid cached chunk display in GT jobs --- .../top-bar/player-navigation.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx index 2088d14d7ccf..f1a2e9cf2892 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx @@ -169,17 +169,14 @@ function PlayerNavigation(props: Props): JSX.Element { {!!ranges && ( {ranges.split(';').map((range) => { - const [start, end] = range.split(':').map((num) => +num); - const adjustedStart = Math.max(0, start - 1); - let totalSegments = stopFrame - startFrame; - if (totalSegments === 0) { - // corner case for jobs with one image - totalSegments = 1; - } + const [rangeStart, rangeStop] = range.split(':').map((num) => +num); + const totalSegments = stopFrame - startFrame + 1; const segmentWidth = 1000 / totalSegments; - const width = Math.max((end - adjustedStart), 1) * segmentWidth; - const offset = (Math.max((adjustedStart - startFrame), 0) / totalSegments) * 1000; - return (); + const width = (rangeStop - rangeStart + 1) * segmentWidth; + const offset = (Math.max((rangeStart - startFrame), 0) / totalSegments) * 1000; + return ( + + ); })} )} From 08ddd288f7bd2d16ce07a9ddb754f58802cc53f6 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 17 Sep 2024 12:41:24 +0300 Subject: [PATCH 168/227] Fix invalid task preview generation --- cvat/apps/engine/frame_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 314c8cf5b23f..fac158b36390 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -260,7 +260,7 @@ def get_rel_frame_number(self, abs_frame_number: int) -> int: return super()._get_rel_frame_number(self._db_task.data, abs_frame_number) def get_preview(self) -> DataWithMeta[BytesIO]: - return self._get_segment_frame_provider(self._db_task.data.start_frame).get_preview() + return self._get_segment_frame_provider(0).get_preview() def get_chunk( self, chunk_number: int, *, quality: FrameQuality = FrameQuality.ORIGINAL From 1d969bd7f41c217d813c55d3cfdc9c8b20b737bb Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 17 Sep 2024 12:43:21 +0300 Subject: [PATCH 169/227] Refactor CS previews, context image chunk generation, media cache creation and retrieval --- cvat/apps/engine/cache.py | 155 +++++++++++++++++------------ cvat/apps/engine/frame_provider.py | 28 +++--- cvat/apps/engine/views.py | 2 +- 3 files changed, 109 insertions(+), 76 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index b32dbeef00e9..0bb921fd7b1a 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -26,6 +26,7 @@ Tuple, Type, Union, + overload, ) import av @@ -73,21 +74,20 @@ def _get_checksum(self, value: bytes) -> int: def _get_or_set_cache_item( self, key: str, create_callback: Callable[[], DataWithMime] - ) -> DataWithMime: + ) -> _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}") - if item_data[0]: - item = (item_data[0], item_data[1], self._get_checksum(item_data[0].getbuffer())) + 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) - else: - item = (item_data[0], item_data[1], None) return item - item = self._get(key) + item = self._get_cache_item(key) if not item: item = create_item() else: @@ -98,9 +98,9 @@ def create_item() -> _CacheItem: slogger.glob.info(f"Recreating cache item {key} due to checksum mismatch") item = create_item() - return item[0], item[1] + return item - def _get(self, key: str) -> Optional[DataWithMime]: + 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) @@ -152,20 +152,36 @@ def _make_segment_task_chunk_key( def _make_context_image_preview_key(self, db_data: models.Data, frame_number: int) -> str: return f"context_image_{db_data.id}_{frame_number}_preview" - def get_segment_chunk( + @overload + def _to_data_with_mime(self, cache_item: _CacheItem) -> DataWithMime: ... + + @overload + def _to_data_with_mime(self, cache_item: Optional[_CacheItem]) -> Optional[DataWithMime]: ... + + def _to_data_with_mime(self, cache_item: Optional[_CacheItem]) -> Optional[DataWithMime]: + if not cache_item: + return None + + return cache_item[:2] + + def get_or_set_segment_chunk( self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality ) -> DataWithMime: - return 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 - ), + 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 + ), + ) ) def get_task_chunk( self, db_task: models.Task, chunk_number: int, *, quality: FrameQuality ) -> Optional[DataWithMime]: - return self._get(key=self._make_chunk_key(db_task, chunk_number, quality=quality)) + return self._to_data_with_mime( + self._get_cache_item(key=self._make_chunk_key(db_task, chunk_number, quality=quality)) + ) def get_or_set_task_chunk( self, @@ -175,16 +191,20 @@ def get_or_set_task_chunk( quality: FrameQuality, set_callback: Callable[[], DataWithMime], ) -> DataWithMime: - return self._get_or_set_cache_item( - key=self._make_chunk_key(db_task, chunk_number, quality=quality), - create_callback=set_callback, + 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, + ) ) def get_segment_task_chunk( self, db_segment: models.Segment, chunk_number: int, *, quality: FrameQuality ) -> Optional[DataWithMime]: - return self._get( - key=self._make_segment_task_chunk_key(db_segment, chunk_number, quality=quality) + return self._to_data_with_mime( + self._get_cache_item( + key=self._make_segment_task_chunk_key(db_segment, chunk_number, quality=quality) + ) ) def get_or_set_segment_task_chunk( @@ -195,40 +215,52 @@ def get_or_set_segment_task_chunk( quality: FrameQuality, set_callback: Callable[[], DataWithMime], ) -> DataWithMime: - return self._get_or_set_cache_item( - key=self._make_segment_task_chunk_key(db_segment, chunk_number, quality=quality), - create_callback=set_callback, + 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, + ) ) - def get_selective_job_chunk( + def get_or_set_selective_job_chunk( self, db_job: models.Job, chunk_number: int, *, quality: FrameQuality ) -> DataWithMime: - return 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 - ), + 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 + ), + ) ) def get_or_set_segment_preview(self, db_segment: models.Segment) -> DataWithMime: - return self._get_or_set_cache_item( - self._make_preview_key(db_segment), - create_callback=lambda: self._prepare_segment_preview(db_segment), + 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), + ) ) def get_cloud_preview(self, db_storage: models.CloudStorage) -> Optional[DataWithMime]: - return self._get(self._make_preview_key(db_storage)) + return self._to_data_with_mime(self._get_cache_item(self._make_preview_key(db_storage))) def get_or_set_cloud_preview(self, db_storage: models.CloudStorage) -> DataWithMime: - return self._get_or_set_cache_item( - self._make_preview_key(db_storage), - create_callback=lambda: self._prepare_cloud_preview(db_storage), + 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), + ) ) - def get_frame_context_images(self, db_data: models.Data, frame_number: int) -> DataWithMime: - return self._get_or_set_cache_item( - key=self._make_context_image_preview_key(db_data, frame_number), - create_callback=lambda: self.prepare_context_images(db_data, frame_number), + def get_or_set_frame_context_images_chunk( + self, db_data: models.Data, frame_number: int + ) -> 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), + ) ) def _read_raw_images( @@ -536,59 +568,56 @@ def _prepare_segment_preview(self, db_segment: models.Segment) -> DataWithMime: return prepare_preview_image(preview) - def _prepare_cloud_preview(self, db_storage): + def _prepare_cloud_preview(self, db_storage: models.CloudStorage) -> DataWithMime: 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") + preview_path = None - for manifest_model in db_storage.manifests.all(): - manifest_prefix = os.path.dirname(manifest_model.filename) + for db_manifest in db_storage.manifests.all(): + manifest_prefix = os.path.dirname(db_manifest.filename) + full_manifest_path = os.path.join( - db_storage.get_storage_dirname(), manifest_model.filename + db_storage.get_storage_dirname(), db_manifest.filename ) if not os.path.exists(full_manifest_path) or datetime.fromtimestamp( os.path.getmtime(full_manifest_path), tz=timezone.utc - ) < storage.get_file_last_modified(manifest_model.filename): - storage.download_file(manifest_model.filename, full_manifest_path) + ) < storage.get_file_last_modified(db_manifest.filename): + storage.download_file(db_manifest.filename, full_manifest_path) + manifest = ImageManifestManager( - os.path.join(db_storage.get_storage_dirname(), manifest_model.filename), + os.path.join(db_storage.get_storage_dirname(), db_manifest.filename), db_storage.get_storage_dirname(), ) # need to update index manifest.set_index() if not len(manifest): continue + preview_info = manifest[0] preview_filename = "".join([preview_info["name"], preview_info["extension"]]) preview_path = os.path.join(manifest_prefix, preview_filename) break + if not preview_path: msg = "Cloud storage {} does not contain any images".format(db_storage.pk) slogger.cloud_storage[db_storage.pk].info(msg) raise NotFound(msg) buff = storage.download_fileobj(preview_path) - mime_type = mimetypes.guess_type(preview_path)[0] - - return buff, mime_type + image = PIL.Image.open(buff) + return prepare_preview_image(image) - def prepare_context_images( - self, db_data: models.Data, frame_number: int - ) -> Optional[DataWithMime]: + def prepare_context_images_chunk(self, db_data: models.Data, frame_number: int) -> DataWithMime: zip_buffer = io.BytesIO() - try: - image = models.Image.objects.get(data_id=db_data.id, frame=frame_number) - except models.Image.DoesNotExist: - return None - if not image.related_files.count(): - return None, None + related_images = db_data.related_files.filter(primary_image__frame=frame_number).all() + if not related_images: + return zip_buffer, "" with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file: - common_path = os.path.commonpath( - list(map(lambda x: str(x.path), image.related_files.all())) - ) - for i in image.related_files.all(): + common_path = os.path.commonpath(list(map(lambda x: str(x.path), related_images))) + for i in related_images: path = os.path.realpath(str(i.path)) name = os.path.relpath(str(i.path), common_path) image = cv2.imread(path) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index fac158b36390..ea14b40a75ad 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -200,7 +200,7 @@ def get_frame( ) -> DataWithMeta[AnyFrame]: ... @abstractmethod - def get_frame_context_images( + def get_frame_context_images_chunk( self, frame_number: int, ) -> Optional[DataWithMeta[BytesIO]]: ... @@ -350,11 +350,13 @@ def get_frame( frame_number, quality=quality, out_type=out_type ) - def get_frame_context_images( + def get_frame_context_images_chunk( self, frame_number: int, ) -> Optional[DataWithMeta[BytesIO]]: - return self._get_segment_frame_provider(frame_number).get_frame_context_images(frame_number) + return self._get_segment_frame_provider(frame_number).get_frame_context_images_chunk( + frame_number + ) def iterate_frames( self, @@ -432,7 +434,7 @@ def __init__(self, db_segment: models.Segment) -> None: self._loaders[FrameQuality.COMPRESSED] = _BufferChunkLoader( reader_class=reader_class[db_data.compressed_chunk_type][0], reader_params=reader_class[db_data.compressed_chunk_type][1], - get_chunk_callback=lambda chunk_idx: cache.get_segment_chunk( + get_chunk_callback=lambda chunk_idx: cache.get_or_set_segment_chunk( db_segment, chunk_idx, quality=FrameQuality.COMPRESSED ), ) @@ -440,7 +442,7 @@ def __init__(self, db_segment: models.Segment) -> None: self._loaders[FrameQuality.ORIGINAL] = _BufferChunkLoader( reader_class=reader_class[db_data.original_chunk_type][0], reader_params=reader_class[db_data.original_chunk_type][1], - get_chunk_callback=lambda chunk_idx: cache.get_segment_chunk( + get_chunk_callback=lambda chunk_idx: cache.get_or_set_segment_chunk( db_segment, chunk_idx, quality=FrameQuality.ORIGINAL ), ) @@ -549,19 +551,21 @@ def get_frame( return return_type(frame, mime=mimetypes.guess_type(frame_name)[0]) - def get_frame_context_images( + def get_frame_context_images_chunk( self, frame_number: int, ) -> Optional[DataWithMeta[BytesIO]]: - # TODO: refactor, optimize - cache = MediaCache() + self.validate_frame_number(frame_number) - if self._db_segment.task.data.storage_method == models.StorageMethodChoice.CACHE: - data, mime = cache.get_frame_context_images(self._db_segment.task.data, frame_number) + db_data = self._db_segment.task.data + + cache = MediaCache() + if db_data.storage_method == models.StorageMethodChoice.CACHE: + data, mime = cache.get_or_set_frame_context_images_chunk(db_data, frame_number) else: - data, mime = cache.prepare_context_images(self._db_segment.task.data, frame_number) + data, mime = cache.prepare_context_images_chunk(db_data, frame_number) - if not data: + if not data.getvalue(): return None return DataWithMeta[BytesIO](data, mime=mime) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index e30a857d3010..e6b51c408809 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -703,7 +703,7 @@ def __call__(self): return HttpResponse(data.data.getvalue(), content_type=data.mime) elif self.type == 'context_image': - data = frame_provider.get_frame_context_images(self.number) + data = frame_provider.get_frame_context_images_chunk(self.number) if not data: return HttpResponseNotFound() From d135475e4792e0ffb745d9a53cc2777a32ae1626 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 17 Sep 2024 12:46:19 +0300 Subject: [PATCH 170/227] Remove extra import --- cvat/apps/engine/cache.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 0bb921fd7b1a..bc4c8616bd7f 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -54,7 +54,6 @@ ZipChunkWriter, ZipCompressedChunkWriter, ) -from cvat.apps.engine.mime_types import mimetypes from cvat.apps.engine.utils import md5_hash, preload_images from utils.dataset_manifest import ImageManifestManager From a1638c94dade78a6e2835f51057c6ca60c6d1c01 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 17 Sep 2024 15:24:50 +0300 Subject: [PATCH 171/227] Fix CS preview in response --- cvat/apps/engine/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index e6b51c408809..8ca23ab94880 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -2726,10 +2726,10 @@ def preview(self, request, pk): result = cache.get_cloud_preview(db_storage) if not result: return HttpResponseNotFound('Cloud storage preview not found') - return HttpResponse(result[0], result[1]) + return HttpResponse(result[0].getvalue(), result[1]) preview, mime = cache.get_or_set_cloud_preview(db_storage) - return HttpResponse(preview, mime) + return HttpResponse(preview.getvalue(), mime) except CloudStorageModel.DoesNotExist: message = f"Storage {pk} does not exist" slogger.glob.error(message) From fc89c015ed5aa7fdd1d85d492715cdff295bc9f7 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 17 Sep 2024 17:34:04 +0300 Subject: [PATCH 172/227] Add reverse migration --- .../migrations/0083_move_to_segment_chunks.py | 95 +++++++++++++++---- 1 file changed, 79 insertions(+), 16 deletions(-) diff --git a/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py b/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py index c9f59593d23b..8ef887d4c54b 100644 --- a/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py +++ b/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py @@ -1,39 +1,67 @@ # Generated by Django 4.2.13 on 2024-08-12 09:49 import os +from itertools import islice +from typing import Iterable, TypeVar + from django.db import migrations -from cvat.apps.engine.log import get_migration_logger, get_migration_log_dir + +from cvat.apps.engine.log import get_migration_log_dir, get_migration_logger + +T = TypeVar("T") + + +def take_by(iterable: Iterable[T], count: int) -> Iterable[T]: + """ + Returns elements from the input iterable by batches of N items. + ('abcdefg', 3) -> ['a', 'b', 'c'], ['d', 'e', 'f'], ['g'] + """ + + it = iter(iterable) + while True: + batch = list(islice(it, count)) + if len(batch) == 0: + break + + yield batch + + +def get_migration_name() -> str: + return os.path.splitext(os.path.basename(__file__))[0] + + +def get_updated_ids_filename(log_dir: str, migration_name: str) -> str: + return os.path.join(log_dir, migration_name + "-data_ids.log") + + +MIGRATION_LOG_HEADER = ( + 'The following Data ids have been switched from using "filesystem" chunk storage ' 'to "cache":' +) + def switch_tasks_with_static_chunks_to_dynamic_chunks(apps, schema_editor): - migration_name = os.path.splitext(os.path.basename(__file__))[0] + migration_name = get_migration_name() migration_log_dir = get_migration_log_dir() with get_migration_logger(migration_name) as common_logger: Data = apps.get_model("engine", "Data") - data_with_static_cache_query = ( - Data.objects - .filter(storage_method="file_system") - ) + data_with_static_cache_query = Data.objects.filter(storage_method="file_system") data_with_static_cache_ids = list( v[0] for v in ( - data_with_static_cache_query - .order_by('id') - .values_list('id') + data_with_static_cache_query.order_by("id") + .values_list("id") .iterator(chunk_size=100000) ) ) data_with_static_cache_query.update(storage_method="cache") - updated_ids_filename = os.path.join(migration_log_dir, migration_name + "-data_ids.log") + updated_ids_filename = get_updated_ids_filename(migration_log_dir, migration_name) with open(updated_ids_filename, "w") as data_ids_file: - print( - "The following Data ids have been switched from using \"filesystem\" chunk storage " - "to \"cache\":", - file=data_ids_file - ) + print(MIGRATION_LOG_HEADER, file=data_ids_file) + for data_id in data_with_static_cache_ids: print(data_id, file=data_ids_file) @@ -44,6 +72,38 @@ def switch_tasks_with_static_chunks_to_dynamic_chunks(apps, schema_editor): ) ) + +def revert_switch_tasks_with_static_chunks_to_dynamic_chunks(apps, schema_editor): + migration_name = get_migration_name() + migration_log_dir = get_migration_log_dir() + + updated_ids_filename = get_updated_ids_filename(migration_log_dir, migration_name) + if not os.path.isfile(updated_ids_filename): + raise FileNotFoundError( + "Can't revert the migration: can't file forward migration logfile at " + f"'{updated_ids_filename}'." + ) + + with open(updated_ids_filename, "r") as data_ids_file: + header = data_ids_file.readline().strip() + if header != MIGRATION_LOG_HEADER: + raise ValueError( + "Can't revert the migration: the migration log file has unexpected header" + ) + + forward_updated_ids = tuple(map(int, data_ids_file)) + + if not forward_updated_ids: + return + + Data = apps.get_model("engine", "Data") + + for id_batch in take_by(forward_updated_ids, 1000): + Data.objects.filter(storage_method="cache", id__in=id_batch).update( + storage_method="file_system" + ) + + class Migration(migrations.Migration): dependencies = [ @@ -51,5 +111,8 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(switch_tasks_with_static_chunks_to_dynamic_chunks) + migrations.RunPython( + switch_tasks_with_static_chunks_to_dynamic_chunks, + reverse_code=revert_switch_tasks_with_static_chunks_to_dynamic_chunks, + ) ] From 85a624415b78b28843e1cc708003af8bf0614ebd Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 17 Sep 2024 20:21:08 +0300 Subject: [PATCH 173/227] Add gt job creation tests, update job tests --- cvat/apps/engine/field_validation.py | 8 +- cvat/apps/engine/serializers.py | 35 ++--- cvat/schema.yml | 10 -- tests/python/rest_api/test_jobs.py | 218 +++++++++++++++++++-------- 4 files changed, 164 insertions(+), 107 deletions(-) diff --git a/cvat/apps/engine/field_validation.py b/cvat/apps/engine/field_validation.py index 02b2d90a3f1b..bac48af4301b 100644 --- a/cvat/apps/engine/field_validation.py +++ b/cvat/apps/engine/field_validation.py @@ -2,17 +2,11 @@ # # SPDX-License-Identifier: MIT -from typing import Any, Optional, Sequence +from typing import Any, Sequence from rest_framework import serializers -def drop_null_keys(d: dict[str, Any], *, keys: Optional[Sequence[str]] = None) -> dict[str, Any]: - if keys is None: - keys = d.keys() - return {k: v for k, v in d.items() if k in keys and v is not None} - - def require_one_of_fields(data: dict[str, Any], keys: Sequence[str]) -> None: active_count = sum(key in data for key in keys) if active_count == 1: diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index c7f60a5ede40..250d68469cf3 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -651,8 +651,6 @@ class JobWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): frames = serializers.ListField( child=serializers.IntegerField(min_value=0), required=False, - allow_null=True, - default=None, help_text=textwrap.dedent("""\ The list of frame ids. Applicable only to the "{}" frame selection method """.format(models.JobFrameSelectionMethod.MANUAL)) @@ -667,7 +665,6 @@ class JobWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): ) frame_share = serializers.FloatField( required=False, - allow_null=True, validators=[field_validation.validate_percent], help_text=textwrap.dedent("""\ The share of frames included in the GT job. @@ -677,7 +674,6 @@ class JobWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): frames_per_job_count = serializers.IntegerField( min_value=1, required=False, - allow_null=True, help_text=textwrap.dedent("""\ The number of frames included in the GT job from each annotation job. Applicable only to the "{}" frame selection method @@ -685,7 +681,6 @@ class JobWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): ) frames_per_job_share = serializers.FloatField( required=False, - allow_null=True, validators=[field_validation.validate_percent], help_text=textwrap.dedent("""\ The share of frames included in the GT job from each annotation job. @@ -695,7 +690,6 @@ class JobWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): random_seed = serializers.IntegerField( min_value=0, required=False, - allow_null=True, help_text=textwrap.dedent("""\ The seed value for the random number generator. The same value will produce the same frame sets. @@ -720,8 +714,6 @@ def to_representation(self, instance): return serializer.data def validate(self, attrs): - attrs = field_validation.drop_null_keys(attrs) - frame_selection_method = attrs.get('frame_selection_method') if frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: field_validation.require_one_of_fields(attrs, ['frame_count', 'frame_share']) @@ -766,11 +758,14 @@ def create(self, validated_data): ) if ( - hasattr(task.data, 'validation_layout') and - task.data.validation_layout.mode == models.ValidationMode.GT_POOL + (validation_layout := getattr(task.data, 'validation_layout', None)) and + ( + validation_layout.mode == models.ValidationMode.GT_POOL or + validation_layout.mode == models.ValidationMode.GT + ) ): raise serializers.ValidationError( - f'Task with validation mode "{models.ValidationMode.GT_POOL}" ' + f'Task with validation mode "{validation_layout.mode}" ' 'cannot have more than 1 GT job' ) @@ -817,7 +812,7 @@ def create(self, validated_data): f"must be not be greater than the segment size ({task.segment_size})" ) elif frame_share := validated_data.pop("frames_per_job_share", None): - frame_count = max(1, int(frame_share * task_size)) + frame_count = max(1, int(frame_share * task.segment_size)) else: raise serializers.ValidationError( "The number of validation frames is not specified" @@ -870,10 +865,6 @@ def create(self, validated_data): f"Unexpected frame selection method '{frame_selection_method}'" ) - task.data.update_validation_layout( - models.ValidationLayout(mode=models.ValidationMode.GT, frames=frames) - ) - # Save the new job segment = models.Segment.objects.create( start_frame=0, @@ -897,6 +888,10 @@ def create(self, validated_data): job.make_dirs() + task.data.update_validation_layout( + models.ValidationLayout(mode=models.ValidationMode.GT, frames=frames) + ) + return job def update(self, instance, validated_data): @@ -1045,9 +1040,7 @@ class ValidationParamsSerializer(serializers.ModelSerializer): frames = serializers.ListField( write_only=True, child=serializers.CharField(max_length=MAX_FILENAME_LENGTH), - default=None, required=False, - allow_null=True, help_text=textwrap.dedent("""\ The list of file names to be included in the validation set. Applicable only to the "{}" frame selection method. @@ -1064,7 +1057,6 @@ class ValidationParamsSerializer(serializers.ModelSerializer): ) frame_share = serializers.FloatField( required=False, - allow_null=True, validators=[field_validation.validate_percent], help_text=textwrap.dedent("""\ The share of frames to be included in the validation set. @@ -1074,7 +1066,6 @@ class ValidationParamsSerializer(serializers.ModelSerializer): frames_per_job_count = serializers.IntegerField( min_value=1, required=False, - allow_null=True, help_text=textwrap.dedent("""\ The number of frames to be included in the validation set from each annotation job. Applicable only to the "{}" frame selection method @@ -1082,7 +1073,6 @@ class ValidationParamsSerializer(serializers.ModelSerializer): ) frames_per_job_share = serializers.FloatField( required=False, - allow_null=True, validators=[field_validation.validate_percent], help_text=textwrap.dedent("""\ The share of frames to be included in the validation set from each annotation job. @@ -1092,7 +1082,6 @@ class ValidationParamsSerializer(serializers.ModelSerializer): random_seed = serializers.IntegerField( min_value=0, required=False, - allow_null=True, help_text=textwrap.dedent("""\ The seed value for the random number generator. The same value will produce the same frame sets. @@ -1109,8 +1098,6 @@ class Meta: model = models.ValidationParams def validate(self, attrs): - attrs = field_validation.drop_null_keys(attrs) - if attrs["mode"] == models.ValidationMode.GT: field_validation.require_one_of_values( attrs, diff --git a/cvat/schema.yml b/cvat/schema.yml index b3d0e96d67d9..49613ea78366 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -8180,28 +8180,24 @@ components: frame_share: type: number format: double - nullable: true description: | The share of frames included in the GT job. Applicable only to the "random_uniform" frame selection method frames_per_job_count: type: integer minimum: 1 - nullable: true description: | The number of frames included in the GT job from each annotation job. Applicable only to the "random_per_job" frame selection method frames_per_job_share: type: number format: double - nullable: true description: | The share of frames included in the GT job from each annotation job. Applicable only to the "random_per_job" frame selection method random_seed: type: integer minimum: 0 - nullable: true description: | The seed value for the random number generator. The same value will produce the same frame sets. @@ -8212,7 +8208,6 @@ components: items: type: integer minimum: 0 - nullable: true description: | The list of frame ids. Applicable only to the "manual" frame selection method required: @@ -10816,7 +10811,6 @@ components: random_seed: type: integer minimum: 0 - nullable: true description: | The seed value for the random number generator. The same value will produce the same frame sets. @@ -10829,7 +10823,6 @@ components: minLength: 1 maxLength: 1024 writeOnly: true - nullable: true description: | The list of file names to be included in the validation set. Applicable only to the "manual" frame selection method. @@ -10843,21 +10836,18 @@ components: frame_share: type: number format: double - nullable: true description: | The share of frames to be included in the validation set. Applicable only to the "random_uniform" frame selection method frames_per_job_count: type: integer minimum: 1 - nullable: true description: | The number of frames to be included in the validation set from each annotation job. Applicable only to the "random_per_job" frame selection method frames_per_job_share: type: number format: double - nullable: true description: | The share of frames to be included in the validation set from each annotation job. Applicable only to the "random_per_job" frame selection method diff --git a/tests/python/rest_api/test_jobs.py b/tests/python/rest_api/test_jobs.py index a6cd225a5d52..bba56dfe7583 100644 --- a/tests/python/rest_api/test_jobs.py +++ b/tests/python/rest_api/test_jobs.py @@ -5,6 +5,7 @@ import io import json +import math import os import xml.etree.ElementTree as ET import zipfile @@ -21,6 +22,7 @@ from cvat_sdk.core.helpers import get_paginated_collection from deepdiff import DeepDiff from PIL import Image +from pytest_cases import parametrize from shared.utils.config import make_api_client from shared.utils.helpers import generate_image_files @@ -30,6 +32,7 @@ compare_annotations, create_task, export_job_dataset, + parse_frame_step, ) @@ -86,74 +89,130 @@ def _test_create_job_fails( assert response.status == expected_status return response + @parametrize( + "frame_selection_method, method_params", + [ + *tuple(product(["random_uniform"], [{"frame_count"}, {"frame_share"}])), + *tuple( + product(["random_per_job"], [{"frames_per_job_count"}, {"frames_per_job_share"}]) + ), + ("manual", {}), + ], + idgen=lambda **args: "-".join([args["frame_selection_method"], *args["method_params"]]), + ) @pytest.mark.parametrize("task_mode", ["annotation", "interpolation"]) - def test_can_create_gt_job_with_manual_frames(self, admin_user, tasks, jobs, task_mode): - user = admin_user - job_frame_count = 4 + def test_can_gt_job_in_a_task( + self, + admin_user, + tasks, + task_mode: str, + frame_selection_method: str, + method_params: set[str], + ): + required_task_size = 15 + task = next( t for t in tasks - if not t["project_id"] - and not t["organization"] - and t["mode"] == task_mode - and t["size"] > job_frame_count - and not any(j for j in jobs if j["task_id"] == t["id"] and j["type"] == "ground_truth") + if t["mode"] == task_mode + if required_task_size <= t["size"] + if not t["validation_mode"] ) task_id = task["id"] - with make_api_client(user) as api_client: - (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) - frame_step = int(task_meta.frame_filter.split("=")[-1]) if task_meta.frame_filter else 1 - job_frame_ids = list(range(task_meta.start_frame, task_meta.stop_frame, frame_step))[ - :job_frame_count - ] - job_spec = { + segment_size = task["segment_size"] + total_frame_count = task["size"] + + job_params = { "task_id": task_id, "type": "ground_truth", - "frame_selection_method": "manual", - "frames": job_frame_ids, + "frame_selection_method": frame_selection_method, } - response = self._test_create_job_ok(user, job_spec) - job_id = json.loads(response.data)["id"] - - with make_api_client(user) as api_client: - (gt_job_meta, _) = api_client.jobs_api.retrieve_data_meta(job_id) - - assert job_frame_count == gt_job_meta.size - assert job_frame_ids == gt_job_meta.included_frames + if "random" in frame_selection_method: + job_params["random_seed"] = 42 + + if frame_selection_method == "random_uniform": + validation_frames_count = 5 + + for method_param in method_params: + if method_param == "frame_count": + job_params[method_param] = validation_frames_count + elif method_param == "frame_share": + job_params[method_param] = validation_frames_count / total_frame_count + else: + assert False + elif frame_selection_method == "random_per_job": + validation_per_job_count = 2 + validation_frames_count = validation_per_job_count * math.ceil( + total_frame_count / segment_size + ) - @pytest.mark.parametrize("task_mode", ["annotation", "interpolation"]) - def test_can_create_gt_job_with_random_frames(self, admin_user, tasks, jobs, task_mode): - user = admin_user - job_frame_count = 3 - required_task_frame_count = job_frame_count + 1 - task = next( - t - for t in tasks - if not t["project_id"] - and not t["organization"] - and t["mode"] == task_mode - and t["size"] > required_task_frame_count - and not any(j for j in jobs if j["task_id"] == t["id"] and j["type"] == "ground_truth") - ) - task_id = task["id"] + for method_param in method_params: + if method_param == "frames_per_job_count": + job_params[method_param] = validation_per_job_count + elif method_param == "frames_per_job_share": + job_params[method_param] = validation_per_job_count / segment_size + else: + assert False + elif frame_selection_method == "manual": + validation_frames_count = 5 + + rng = np.random.Generator(np.random.MT19937(seed=42)) + job_params["frames"] = rng.choice( + range(total_frame_count), validation_frames_count, replace=False + ).tolist() + else: + assert False - job_spec = { - "task_id": task_id, - "type": "ground_truth", - "frame_selection_method": "random_uniform", - "frame_count": job_frame_count, - } + with make_api_client(admin_user) as api_client: + (gt_job, _) = api_client.jobs_api.create(job_write_request=job_params) + + # GT jobs occupy the whole task frame range + assert gt_job.start_frame == 0 + assert gt_job.stop_frame + 1 == task["size"] + assert gt_job.type == "ground_truth" + assert gt_job.task_id == task_id + + annotation_job_metas = [ + api_client.jobs_api.retrieve_data_meta(job.id)[0] + for job in get_paginated_collection( + api_client.jobs_api.list_endpoint, task_id=task_id, type="annotation" + ) + ] + gt_job_metas = [ + api_client.jobs_api.retrieve_data_meta(job.id)[0] + for job in get_paginated_collection( + api_client.jobs_api.list_endpoint, task_id=task_id, type="ground_truth" + ) + ] - response = self._test_create_job_ok(user, job_spec) - job_id = json.loads(response.data)["id"] + assert len(gt_job_metas) == 1 - with make_api_client(user) as api_client: - (gt_job_meta, _) = api_client.jobs_api.retrieve_data_meta(job_id) + frame_step = parse_frame_step(gt_job_metas[0].frame_filter) + validation_frames = [ + abs_frame_id + for abs_frame_id in range( + gt_job_metas[0].start_frame, + gt_job_metas[0].stop_frame + 1, + frame_step, + ) + if abs_frame_id in gt_job_metas[0].included_frames + ] - assert job_frame_count == gt_job_meta.size - assert job_frame_count == len(gt_job_meta.included_frames) + if frame_selection_method == "random_per_job": + # each job must have the specified number of validation frames + for job_meta in annotation_job_metas: + assert ( + len( + set( + range(job_meta.start_frame, job_meta.stop_frame + 1, frame_step) + ).intersection(validation_frames) + ) + == validation_per_job_count + ) + else: + assert len(validation_frames) == validation_frames_count @pytest.mark.parametrize( "task_id, frame_ids", @@ -171,7 +230,7 @@ def test_can_create_gt_job_with_random_frames_and_seed(self, admin_user, task_id "type": "ground_truth", "frame_selection_method": "random_uniform", "frame_count": 3, - "seed": 42, + "random_seed": 42, } response = self._test_create_job_ok(user, job_spec) @@ -209,9 +268,15 @@ def test_can_create_gt_job_with_all_frames(self, admin_user, tasks, jobs, task_m assert task["size"] == gt_job_meta.size - def test_can_create_no_more_than_1_gt_job(self, admin_user, jobs): + @pytest.mark.parametrize("validation_mode", ["gt", "gt_pool"]) + def test_can_create_no_more_than_1_gt_job(self, admin_user, tasks, jobs, validation_mode): user = admin_user - task_id = next(j for j in jobs if j["type"] == "ground_truth")["task_id"] + task_id = next( + j + for j in jobs + if j["type"] == "ground_truth" + if tasks[j["task_id"]]["validation_mode"] == validation_mode + )["task_id"] job_spec = { "task_id": task_id, @@ -223,7 +288,11 @@ def test_can_create_no_more_than_1_gt_job(self, admin_user, jobs): response = self._test_create_job_fails( user, job_spec, expected_status=HTTPStatus.BAD_REQUEST ) - assert b"A task can have only 1 ground truth job" in response.data + + assert ( + f'Task with validation mode \\"{validation_mode}\\" ' + "cannot have more than 1 GT job".encode() in response.data + ) def test_can_create_gt_job_in_sandbox_task(self, tasks, jobs, users): task = next( @@ -362,9 +431,23 @@ def _test_destroy_job_fails(self, user, job_id, *, expected_status: int, **kwarg return response @pytest.mark.usefixtures("restore_cvat_data_per_function") - @pytest.mark.parametrize("job_type, allow", (("ground_truth", True), ("annotation", False))) - def test_destroy_job(self, admin_user, jobs, job_type, allow): - job = next(j for j in jobs if j["type"] == job_type) + @pytest.mark.parametrize( + "validation_mode, job_type, allow", + ( + (None, "annotation", False), + ("gt", "ground_truth", True), + ("gt", "annotation", False), + ("gt_pool", "ground_truth", False), + ("gt_pool", "annotation", False), + ), + ) + def test_destroy_job(self, admin_user, tasks, jobs, validation_mode, job_type, allow): + job = next( + j + for j in jobs + if j["type"] == job_type + if tasks[j["task_id"]]["validation_mode"] == validation_mode + ) if allow: self._test_destroy_job_ok(admin_user, job["id"]) @@ -378,8 +461,8 @@ def test_can_destroy_gt_job_in_sandbox_task(self, tasks, jobs, users, admin_user t for t in tasks if t["organization"] is None - and all(j["type"] != "ground_truth" for j in jobs if j["task_id"] == t["id"]) - and not users[t["owner"]["id"]]["is_superuser"] + if all(j["type"] != "ground_truth" for j in jobs if j["task_id"] == t["id"]) + if not users[t["owner"]["id"]]["is_superuser"] ) user = task["owner"]["username"] @@ -684,8 +767,8 @@ def test_can_get_gt_job_meta_with_complex_frame_setup(self, admin_user, request) ) task_frame_ids = range(start_frame, stop_frame, frame_step) - job_frame_ids = list(task_frame_ids[::3]) - gt_job = self._create_gt_job(admin_user, task_id, job_frame_ids) + gt_frame_ids = list(range(len(task_frame_ids)))[::3] + gt_job = self._create_gt_job(admin_user, task_id, gt_frame_ids) request.addfinalizer(lambda: self._delete_gt_job(admin_user, gt_job.id)) with make_api_client(admin_user) as api_client: @@ -696,8 +779,11 @@ def test_can_get_gt_job_meta_with_complex_frame_setup(self, admin_user, request) assert len(task_frame_ids) - 1 == gt_job.stop_frame # The size is adjusted by the frame step and included frames - assert len(job_frame_ids) == gt_job_meta.size - assert job_frame_ids == gt_job_meta.included_frames + assert len(gt_frame_ids) == gt_job_meta.size + assert ( + list(task_frame_ids[gt_frame] for gt_frame in gt_frame_ids) + == gt_job_meta.included_frames + ) # The frames themselves are the same as in the whole range # with placeholders in the frames outside the job. @@ -705,7 +791,7 @@ def test_can_get_gt_job_meta_with_complex_frame_setup(self, admin_user, request) assert start_frame == gt_job_meta.start_frame assert max(task_frame_ids) == gt_job_meta.stop_frame assert [frame_info["name"] for frame_info in gt_job_meta.frames] == [ - images[frame].name if frame in job_frame_ids else "placeholder.jpg" + images[frame].name if frame in gt_job_meta.included_frames else "placeholder.jpg" for frame in task_frame_ids ] From 2679ff44e8863806a8bdf5ce5dbd83cf0093469b Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 17 Sep 2024 20:25:16 +0300 Subject: [PATCH 174/227] Update imports --- cvat/apps/engine/backup.py | 1 - cvat/apps/engine/task.py | 1 - 2 files changed, 2 deletions(-) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index bed2604b98cb..c847df9d4630 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -4,7 +4,6 @@ # SPDX-License-Identifier: MIT import io -import itertools import mimetypes import os import re diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 8f2b68f3eefa..5b8860509480 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -11,7 +11,6 @@ import rq import shutil from copy import deepcopy -from rest_framework.serializers import ValidationError from contextlib import closing from datetime import datetime, timezone from pathlib import Path From 39c85726caa62a7a3ec69278cbc1cb03af953f50 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 17 Sep 2024 21:19:54 +0300 Subject: [PATCH 175/227] Python 3.8 compatibility --- tests/python/rest_api/test_jobs.py | 4 ++-- tests/python/rest_api/test_tasks.py | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/python/rest_api/test_jobs.py b/tests/python/rest_api/test_jobs.py index bba56dfe7583..7fd68b81e3a7 100644 --- a/tests/python/rest_api/test_jobs.py +++ b/tests/python/rest_api/test_jobs.py @@ -13,7 +13,7 @@ from http import HTTPStatus from io import BytesIO from itertools import groupby, product -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Set, Tuple, Union import numpy as np import pytest @@ -107,7 +107,7 @@ def test_can_gt_job_in_a_task( tasks, task_mode: str, frame_selection_method: str, - method_params: set[str], + method_params: Set[str], ): required_task_size = 15 diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 1cc9e1e361e3..32352936a6ad 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -24,7 +24,19 @@ from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory from time import sleep, time -from typing import Any, Callable, ClassVar, Dict, Generator, List, Optional, Sequence, Tuple, Union +from typing import ( + Any, + Callable, + ClassVar, + Dict, + Generator, + List, + Optional, + Sequence, + Set, + Tuple, + Union, +) import attrs import numpy as np @@ -2106,7 +2118,7 @@ def test_can_create_task_with_honeypots( self, request: pytest.FixtureRequest, frame_selection_method: str, - method_params: set[str], + method_params: Set[str], per_job_count_param: str, ): base_segment_size = 4 From 169b22cdf43cd9679c5562328702a0ce3fdbdfd6 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 18 Sep 2024 12:14:29 +0300 Subject: [PATCH 176/227] Python 3.8 compatibility --- tests/python/rest_api/test_tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 32352936a6ad..d6ec61c652e3 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -2245,7 +2245,7 @@ def test_can_create_task_with_gt_job_from_images( self, request: pytest.FixtureRequest, frame_selection_method: str, - method_params: set[str], + method_params: Set[str], ): segment_size = 4 total_frame_count = 15 @@ -2368,7 +2368,7 @@ def test_can_create_task_with_gt_job_from_video( self, request: pytest.FixtureRequest, frame_selection_method: str, - method_params: set[str], + method_params: Set[str], ): segment_size = 4 total_frame_count = 15 From ee822acbe16d12a4eebb3b0b15488c5c2923e2f9 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 18 Sep 2024 19:35:00 +0300 Subject: [PATCH 177/227] Support mapped frames in static chunk creation --- cvat/apps/engine/task.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 5b8860509480..a2ac3753c176 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1338,7 +1338,7 @@ def _update_status(msg: str) -> None: primary_image=image, path=os.path.join(upload_dir, related_file_path), ) - for image in images + for image in images.all() for related_file_path in related_images.get(image.path, []) ] models.RelatedFile.objects.bulk_create(db_related_files) @@ -1482,9 +1482,9 @@ def _update_status(msg: str) -> None: settings.MEDIA_CACHE_ALLOW_STATIC_CACHE and db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM ): - _create_static_chunks(db_task, media_extractor=extractor) + _create_static_chunks(db_task, media_extractor=extractor, upload_dir=upload_dir) -def _create_static_chunks(db_task: models.Task, *, media_extractor: IMediaReader): +def _create_static_chunks(db_task: models.Task, *, media_extractor: IMediaReader, upload_dir: str): @attrs.define class _ChunkProgressUpdater: _call_counter: int = attrs.field(default=0, init=False) @@ -1568,7 +1568,9 @@ def save_chunks( ) original_chunk_writer = original_chunk_writer_class(original_quality, **chunk_writer_kwargs) - db_segments = db_task.segment_set.all() + db_segments = db_task.segment_set.order_by('start_frame').all() + + frame_map = {} # frame number -> extractor frame number if isinstance(media_extractor, MEDIA_TYPES['video']['extractor']): def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: @@ -1587,6 +1589,16 @@ def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: object_size_callback=_get_frame_size ) else: + extractor_frame_ids = { + media_extractor.get_path(abs_frame_number): abs_frame_number + for abs_frame_number in media_extractor.frame_range + } + + frame_map = { + frame.frame: extractor_frame_ids[os.path.join(upload_dir, frame.path)] + for frame in db_data.images.all() + } + media_iterator = RandomAccessIterator(media_extractor) with closing(media_iterator): @@ -1603,13 +1615,16 @@ def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: for segment_idx, db_segment in enumerate(db_segments): frame_counter = itertools.count() for chunk_idx, chunk_frame_ids in ( - (chunk_idx, list(chunk_frame_ids)) + (chunk_idx, tuple(chunk_frame_ids)) for chunk_idx, chunk_frame_ids in itertools.groupby( ( # Convert absolute to relative ids (extractor output positions) # Extractor will skip frames outside requested (abs_frame_id - db_data.start_frame) // frame_step - for abs_frame_id in db_segment.frame_set + for abs_frame_id in ( + frame_map.get(frame, frame) + for frame in db_segment.frame_set + ) ), lambda _: next(frame_counter) // db_data.chunk_size ) From 9f190814fdb9ea7d217dc0cb0613adf75dedeede Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 18 Sep 2024 19:35:24 +0300 Subject: [PATCH 178/227] Improve test task names in fixtures --- tests/python/rest_api/test_tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index d6ec61c652e3..fe14b941ee0f 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -2547,7 +2547,7 @@ def _uploaded_images_task_fxt_base( **data_kwargs, ) -> Generator[Tuple[_ImagesTaskSpec, int], None, None]: task_params = { - "name": request.node.name, + "name": f"{request.node.name}[{request.fixturename}]", "labels": [{"name": "a"}], } if segment_size: @@ -2677,7 +2677,7 @@ def _uploaded_video_task_fxt_base( segment_size: Optional[int] = None, ) -> Generator[Tuple[_VideoTaskSpec, int], None, None]: task_params = { - "name": request.node.name, + "name": f"{request.node.name}[{request.fixturename}]", "labels": [{"name": "a"}], } if segment_size: From e38f2f98f8334977bff4e6ead177f36f1f1c0ebe Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 19 Sep 2024 17:12:43 +0300 Subject: [PATCH 179/227] Add backward compatibility alias for the seed field in job creation --- cvat/apps/engine/serializers.py | 14 ++++++++++++-- cvat/schema.yml | 4 ++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 250d68469cf3..8edbb509bfcd 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -697,12 +697,15 @@ class JobWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): By default, a random value is used. """) ) + seed = serializers.IntegerField( + min_value=0, required=False, help_text="Deprecated. Use random_seed instead." + ) class Meta: model = models.Job random_selection_params = ( 'frame_count', 'frame_share', 'frames_per_job_count', 'frames_per_job_share', - 'random_seed', + 'random_seed', 'seed' ) manual_selection_params = ('frames',) write_once_fields = ('type', 'task_id', 'frame_selection_method',) \ @@ -717,6 +720,11 @@ def validate(self, attrs): frame_selection_method = attrs.get('frame_selection_method') if frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: field_validation.require_one_of_fields(attrs, ['frame_count', 'frame_share']) + + # 'seed' is a backward compatibility alias + if attrs.get('seed') is not None or attrs.get('random_seed') is not None: + field_validation.require_one_of_fields(attrs, ['seed', 'random_seed']) + elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_PER_JOB: field_validation.require_one_of_fields( attrs, ['frames_per_job_count', 'frames_per_job_share'] @@ -789,16 +797,18 @@ def create(self, validated_data): ) seed = validated_data.pop("random_seed", None) + deprecated_seed = validated_data.pop("seed", None) # The RNG backend must not change to yield reproducible results, # so here we specify it explicitly from numpy import random rng = random.Generator(random.MT19937(seed=seed)) - if seed is not None and frame_count < task_size: + if deprecated_seed is not None and frame_count < task_size: # Reproduce the old (a little bit incorrect) behavior that existed before # https://github.com/cvat-ai/cvat/pull/7126 # to make the old seed-based sequences reproducible + rng = random.Generator(random.MT19937(seed=deprecated_seed)) valid_frame_ids = [v for v in valid_frame_ids if v != task.data.stop_frame] frames = rng.choice( diff --git a/cvat/schema.yml b/cvat/schema.yml index 49613ea78366..566cfae26541 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -8203,6 +8203,10 @@ components: The same value will produce the same frame sets. Applicable only to random frame selection methods. By default, a random value is used. + seed: + type: integer + minimum: 0 + description: Deprecated. Use random_seed instead. frames: type: array items: From 5986866f7604ac69126bf8fde553e4ec01ed1c85 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Thu, 19 Sep 2024 18:22:32 +0300 Subject: [PATCH 180/227] Restore old test --- tests/python/rest_api/test_jobs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/rest_api/test_jobs.py b/tests/python/rest_api/test_jobs.py index 7fd68b81e3a7..03204c4702ed 100644 --- a/tests/python/rest_api/test_jobs.py +++ b/tests/python/rest_api/test_jobs.py @@ -230,7 +230,7 @@ def test_can_create_gt_job_with_random_frames_and_seed(self, admin_user, task_id "type": "ground_truth", "frame_selection_method": "random_uniform", "frame_count": 3, - "random_seed": 42, + "seed": 42, } response = self._test_create_job_ok(user, job_spec) From 5bba8049b437f17ce8d37145d6f33a87fa595a42 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 20 Sep 2024 12:52:22 +0300 Subject: [PATCH 181/227] Update ui --- cvat-core/src/session-implementation.ts | 1 + cvat-core/src/session.ts | 9 +- cvat-ui/src/actions/tasks-actions.ts | 20 +-- .../advanced-configuration-form.tsx | 5 +- .../create-task-page/create-task-content.tsx | 72 ++++++++-- .../quality-configuration-form.tsx | 131 ++++++++++++------ 6 files changed, 171 insertions(+), 67 deletions(-) diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 961771708726..adbff942ee46 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -723,6 +723,7 @@ export function implementTask(Task: typeof TaskClass): typeof TaskClass { ...(typeof this.dataChunkSize !== 'undefined' ? { chunk_size: this.dataChunkSize } : {}), ...(typeof this.copyData !== 'undefined' ? { copy_data: this.copyData } : {}), ...(typeof this.cloudStorageId !== 'undefined' ? { cloud_storage_id: this.cloudStorageId } : {}), + ...(fields.validation_params ? { validation_params: fields.validation_params } : {}), }; const { taskID, rqID } = await serverProxy.tasks.create( diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 54133ff6b667..cef5af49431e 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -737,9 +737,9 @@ export class Task extends Session { public readonly cloudStorageId: number; public readonly sortingMethod: string; - public readonly validationMethod: string; + public readonly validationMode: string | null; public readonly validationFramesPercent: number; - public readonly validationFramesPerJob: number; + public readonly validationFramesPerJobPercent: number; public readonly frameSelectionMethod: string; constructor(initialData: Readonly & { @@ -786,6 +786,8 @@ export class Task extends Session { cloud_storage_id: undefined, sorting_method: undefined, files: undefined, + + validation_mode: null, }; const updateTrigger = new FieldUpdateTrigger(); @@ -1113,6 +1115,9 @@ export class Task extends Session { progress: { get: () => data.progress, }, + validationMode: { + get: () => data.validation_mode, + }, _internalData: { get: () => data, }, diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index 70eb56d4c1e9..4fb188b2cbdf 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -11,7 +11,6 @@ import { import { filterNull } from 'utils/filter-null'; import { ThunkDispatch, ThunkAction } from 'utils/redux'; -import { ValidationMethod } from 'components/create-task-page/quality-configuration-form'; import { getInferenceStatusAsync } from './models-actions'; import { updateRequestProgress } from './requests-actions'; @@ -257,19 +256,14 @@ ThunkAction { let extras = {}; - if (data.quality.validationMethod === ValidationMethod.GT) { + if (data.quality.validationMode) { extras = { - validation_method: ValidationMethod.GT, - validation_frames_percent: data.quality.validationFramesPercent, - frame_selection_method: data.quality.frameSelectionMethod, - }; - } - - if (data.quality.validationMethod === ValidationMethod.HONEYPOTS) { - extras = { - validation_method: ValidationMethod.HONEYPOTS, - validation_frames_percent: data.quality.validationFramesPercent, - validation_frames_per_job: data.quality.validationFramesPerJob, + validation_params: { + mode: data.quality.validationMode, + frame_selection_method: data.quality.frameSelectionMethod, + frame_percent: data.quality.validationFramesPercent, + frames_per_job_percent: data.quality.validationFramesPerJobPercent, + }, }; } diff --git a/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx b/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx index f39afbe367f6..40dd1dca08af 100644 --- a/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx @@ -76,6 +76,7 @@ interface Props { onChangeUseProjectTargetStorage(value: boolean): void; onChangeSourceStorageLocation: (value: StorageLocation) => void; onChangeTargetStorageLocation: (value: StorageLocation) => void; + onChangeSortingMethod(value: SortingMethod): void; projectId: number | null; useProjectSourceStorage: boolean; useProjectTargetStorage: boolean; @@ -225,6 +226,8 @@ class AdvancedConfigurationForm extends React.PureComponent { } private renderSortingMethodRadio(): JSX.Element { + const { onChangeSortingMethod } = this.props; + return ( { ]} help='Specify how to sort images. It is not relevant for videos.' > - + onChangeSortingMethod(e.target.value)}> Lexicographical diff --git a/cvat-ui/src/components/create-task-page/create-task-content.tsx b/cvat-ui/src/components/create-task-page/create-task-content.tsx index 655d732c3430..e4161e96701c 100644 --- a/cvat-ui/src/components/create-task-page/create-task-content.tsx +++ b/cvat-ui/src/components/create-task-page/create-task-content.tsx @@ -22,12 +22,13 @@ import { RemoteFile } from 'components/file-manager/remote-browser'; import { getFileContentType, getContentTypeRemoteFile, getFileNameFromPath } from 'utils/files'; import { FrameSelectionMethod } from 'components/create-job-page/job-form'; +import { ArgumentError } from 'cvat-core/src/exceptions'; import BasicConfigurationForm, { BaseConfiguration } from './basic-configuration-form'; import ProjectSearchField from './project-search-field'; import ProjectSubsetField from './project-subset-field'; import MultiTasksProgress from './multi-task-progress'; import AdvancedConfigurationForm, { AdvancedConfiguration, SortingMethod } from './advanced-configuration-form'; -import QualityConfigurationForm, { QualityConfiguration, ValidationMethod } from './quality-configuration-form'; +import QualityConfigurationForm, { QualityConfiguration, ValidationMode } from './quality-configuration-form'; type TabName = 'local' | 'share' | 'remote' | 'cloudStorage'; const core = getCore(); @@ -87,9 +88,9 @@ const defaultState: State = { useProjectTargetStorage: true, }, quality: { - validationMethod: ValidationMethod.NONE, + validationMode: ValidationMode.NONE, validationFramesPercent: 5, - validationFramesPerJob: 1, + validationFramesPerJobPercent: 1, frameSelectionMethod: FrameSelectionMethod.RANDOM, }, labels: [], @@ -303,12 +304,21 @@ class CreateTaskContent extends React.PureComponent { + private handleValidationModeChange = (value: ValidationMode): void => { this.qualityConfigurationComponent.current?.resetFields(); this.setState(() => ({ quality: { ...defaultState.quality, - validationMethod: value, + validationMode: value, + }, + })); + }; + + private handleFrameSelectionMethodChange = (value: FrameSelectionMethod): void => { + this.setState((state) => ({ + quality: { + ...state.quality, + frameSelectionMethod: value, }, })); }; @@ -431,7 +441,7 @@ class CreateTaskContent extends React.PureComponent => new Promise((resolve, reject) => { - const { projectId } = this.state; + const { projectId, quality } = this.state; if (!this.validateLabelsOrProject()) { notification.error({ message: 'Could not create a task', @@ -442,6 +452,21 @@ class CreateTaskContent extends React.PureComponent { + this.setState((state) => ({ + advanced: { + ...state.advanced, + sortingMethod: value, + }, + })); + }; + private getTaskName = (indexFile: number, fileManagerTabName: TabName, defaultFileName = ''): string => { const { many } = this.props; const { basic } = this.state; @@ -745,6 +779,21 @@ class CreateTaskContent extends React.PureComponent { @@ -916,7 +966,7 @@ class CreateTaskContent extends React.PureComponent @@ -930,8 +980,10 @@ class CreateTaskContent extends React.PureComponent ), }]} @@ -1030,7 +1082,7 @@ class CreateTaskContent extends React.PureComponent {many ? this.renderFooterMultiTasks() : this.renderFooterSingleTask() } diff --git a/cvat-ui/src/components/create-task-page/quality-configuration-form.tsx b/cvat-ui/src/components/create-task-page/quality-configuration-form.tsx index bc6f8888cfa0..4814f8118450 100644 --- a/cvat-ui/src/components/create-task-page/quality-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/quality-configuration-form.tsx @@ -12,20 +12,22 @@ import { Col, Row } from 'antd/lib/grid'; import Select from 'antd/lib/select'; export interface QualityConfiguration { - validationMethod: ValidationMethod; + validationMode: ValidationMode; validationFramesPercent: number; - validationFramesPerJob: number; + validationFramesPerJobPercent: number; frameSelectionMethod: FrameSelectionMethod; } interface Props { onSubmit(values: QualityConfiguration): Promise; initialValues: QualityConfiguration; - validationMethod: ValidationMethod; - onChangeValidationMethod: (method: ValidationMethod) => void; + frameSelectionMethod: FrameSelectionMethod; + onChangeFrameSelectionMethod: (method: FrameSelectionMethod) => void; + validationMode: ValidationMode; + onChangeValidationMode: (method: ValidationMode) => void; } -export enum ValidationMethod { +export enum ValidationMode { NONE = 'none', GT = 'gt_job', HONEYPOTS = 'gt_pool', @@ -51,10 +53,12 @@ export default class QualityConfigurationForm extends React.PureComponent } public resetFields(): void { - this.formRef.current?.resetFields(['validationFramesPercent', 'validationFramesPerJob', 'frameSelectionMethod']); + this.formRef.current?.resetFields(['validationFramesPercent', 'validationFramesPerJobPercent', 'frameSelectionMethod']); } private gtParamsBlock(): JSX.Element { + const { frameSelectionMethod, onChangeFrameSelectionMethod } = this.props; + return ( <> @@ -63,33 +67,69 @@ export default class QualityConfigurationForm extends React.PureComponent label='Frame selection method' rules={[{ required: true, message: 'Please, specify frame selection method' }]} > - Random Random per job - - +value} - rules={[ - { required: true, message: 'The field is required' }, - { - type: 'number', min: 0, max: 100, message: 'Value is not valid', - }, - ]} - > - } - /> - - + + { + (frameSelectionMethod === FrameSelectionMethod.RANDOM) ? + ( + + +value} + rules={[ + { required: true, message: 'The field is required' }, + { + type: 'number', min: 0, max: 100, message: 'Value is not valid', + }, + ]} + > + } + /> + + + ) : ('') + } + + { + (frameSelectionMethod === FrameSelectionMethod.RANDOM_PER_JOB) ? + ( + + +value} + rules={[ + { required: true, message: 'The field is required' }, + { + type: 'number', min: 0, max: 100, message: 'Value is not valid', + }, + ]} + > + } + /> + + + ) : ('') + } ); } @@ -97,10 +137,19 @@ export default class QualityConfigurationForm extends React.PureComponent private honeypotsParamsBlock(): JSX.Element { return ( + + From 7308b76198afaad8088ee44bc0a1596ffc975f19 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 23 Sep 2024 16:22:56 +0300 Subject: [PATCH 187/227] Rename real_frame_id to real_frame for consistency --- cvat/apps/dataset_manager/bindings.py | 3 ++ cvat/apps/dataset_manager/task.py | 10 +++--- .../migrations/0084_honeypot_support.py | 34 ++++++++++++------- cvat/apps/engine/models.py | 12 +++++-- cvat/apps/engine/task.py | 10 +++--- 5 files changed, 44 insertions(+), 25 deletions(-) diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index f63abee96e92..74f2868941cf 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -808,6 +808,9 @@ def _init_frame_info(self): if self.abs_frame_id(frame) not in frame_set ) + if self.db_instance.type == JobType.GROUND_TRUTH: + self._excluded_frames.update(self.db_data.validation_layout.disabled_frames) + if self._required_frames: abs_range = self.abs_range self._required_frames = set( diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 53a7f9e400da..71cac16cf89a 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -872,14 +872,14 @@ def _preprocess_input_annotations_for_gt_pool_task( gt_pool_frames = gt_job.segment.frame_set task_validation_frame_groups: dict[int, int] = {} # real_id -> [placeholder_id, ...] task_validation_frame_ids: set[int] = set() - for frame_id, real_frame_id in ( + for frame, real_frame in ( self.db_task.data.images - .filter(is_placeholder=True, real_frame_id__in=gt_pool_frames) - .values_list('frame', 'real_frame_id') + .filter(is_placeholder=True, real_frame__in=gt_pool_frames) + .values_list('frame', 'real_frame') .iterator(chunk_size=1000) ): - task_validation_frame_ids.add(frame_id) - task_validation_frame_groups.setdefault(real_frame_id, []).append(frame_id) + task_validation_frame_ids.add(frame) + task_validation_frame_groups.setdefault(real_frame, []).append(frame) assert sorted(gt_pool_frames) == list(range(min(gt_pool_frames), max(gt_pool_frames) + 1)) gt_annotations = data.slice(min(gt_pool_frames), max(gt_pool_frames)) diff --git a/cvat/apps/engine/migrations/0084_honeypot_support.py b/cvat/apps/engine/migrations/0084_honeypot_support.py index 2e3e021f27fb..f64535003c69 100644 --- a/cvat/apps/engine/migrations/0084_honeypot_support.py +++ b/cvat/apps/engine/migrations/0084_honeypot_support.py @@ -1,19 +1,24 @@ -# Generated by Django 4.2.15 on 2024-09-13 11:34 +# Generated by Django 4.2.15 on 2024-09-23 13:11 from typing import Collection -import cvat.apps.engine.models -from django.db import migrations, models + import django.db.models.deletion +from django.db import migrations, models + +import cvat.apps.engine.models + def get_frame_step(db_data) -> int: v = db_data.frame_filter or "step=1" return int(v.split("=")[-1]) + def get_rel_frame(abs_frame: int, db_data) -> int: data_start_frame = db_data.start_frame step = get_frame_step(db_data) return (abs_frame - data_start_frame) // step + def get_segment_rel_frame_set(db_segment) -> Collection[int]: db_data = db_segment.task.data data_start_frame = db_data.start_frame @@ -22,12 +27,12 @@ def get_segment_rel_frame_set(db_segment) -> Collection[int]: frame_range = range( data_start_frame + db_segment.start_frame * step, min(data_start_frame + db_segment.stop_frame * step, data_stop_frame) + step, - step + step, ) - if db_segment.type == 'range': + if db_segment.type == "range": frame_set = frame_range - elif db_segment.type == 'specific_frames': + elif db_segment.type == "specific_frames": frame_set = set(frame_range).intersection(db_segment.frames or []) else: assert False @@ -40,9 +45,8 @@ def init_validation_layout_in_tasks_with_gt_job(apps, schema_editor): ValidationLayout = apps.get_model("engine", "ValidationLayout") gt_jobs = ( - Job.objects - .filter(type="ground_truth") - .select_related('segment', 'segment__task', 'segment__task__data') + Job.objects.filter(type="ground_truth") + .select_related("segment", "segment__task", "segment__task__data") .iterator(chunk_size=100) ) @@ -51,7 +55,7 @@ def init_validation_layout_in_tasks_with_gt_job(apps, schema_editor): validation_layout = ValidationLayout( task_data=gt_job.segment.task.data, mode="gt", - frames=get_segment_rel_frame_set(gt_job.segment) + frames=get_segment_rel_frame_set(gt_job.segment), ) validation_layouts.append(validation_layout) @@ -72,7 +76,7 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name="image", - name="real_frame_id", + name="real_frame", field=models.PositiveIntegerField(default=0), ), migrations.CreateModel( @@ -127,8 +131,9 @@ class Migration(migrations.Migration): "mode", models.CharField(choices=[("gt", "GT"), ("gt_pool", "GT_POOL")], max_length=32), ), - ("frames", cvat.apps.engine.models.IntArrayField(default="")), ("frames_per_job_count", models.IntegerField(null=True)), + ("frames", cvat.apps.engine.models.IntArrayField(default="")), + ("disabled_frames", cvat.apps.engine.models.IntArrayField(default="")), ( "task_data", models.OneToOneField( @@ -159,5 +164,8 @@ class Migration(migrations.Migration): ), ], ), - migrations.RunPython(init_validation_layout_in_tasks_with_gt_job) + migrations.RunPython( + init_validation_layout_in_tasks_with_gt_job, + reverse_code=migrations.RunPython.noop, + ), ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index bbf3bf56ad0d..171ea600a36e 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -256,14 +256,22 @@ class ValidationFrame(models.Model): path = models.CharField(max_length=1024, default='') class ValidationLayout(models.Model): + "Represents validation configuration in a task" + task_data = models.OneToOneField( 'Data', on_delete=models.CASCADE, related_name="validation_layout" ) mode = models.CharField(max_length=32, choices=ValidationMode.choices()) - frames = IntArrayField(store_sorted=True, unique_values=True) + frames_per_job_count = models.IntegerField(null=True) + frames = IntArrayField(store_sorted=True, unique_values=True) + "Stores task frame numbers of the validation frames" + + disabled_frames = IntArrayField(store_sorted=True, unique_values=True) + "Stores task frame numbers of the disabled (deleted) validation frames" + class Data(models.Model): MANIFEST_FILENAME: ClassVar[str] = 'manifest.jsonl' @@ -387,7 +395,7 @@ class Image(models.Model): width = models.PositiveIntegerField() height = models.PositiveIntegerField() is_placeholder = models.BooleanField(default=False) - real_frame_id = models.PositiveIntegerField(default=0) + real_frame = models.PositiveIntegerField(default=0) class Meta: default_permissions = () diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 35e1295fc9af..71a90df489d3 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1147,10 +1147,10 @@ def _update_status(msg: str) -> None: # Store information about the real frame placement in validation frames in jobs for image in images[:-len(validation_params['frames'])]: - real_frame_idx = frame_idx_map.get(image.path) - if real_frame_idx is not None: + real_frame = frame_idx_map.get(image.path) + if real_frame is not None: image.is_placeholder = True - image.real_frame_id = real_frame_idx + image.real_frame = real_frame # Exclude the previous GT job from the list of jobs to be created with normal segments # It must be the last one @@ -1273,7 +1273,7 @@ def _update_status(msg: str) -> None: if job_frame in job_validation_frames: image.is_placeholder = True - image.real_frame_id = job_frame + image.real_frame = job_frame validation_frames.append(image.frame) job_images.append(image.path) @@ -1304,7 +1304,7 @@ def _update_status(msg: str) -> None: for validation_frame in validation_frames: image = new_db_images[validation_frame] assert image.is_placeholder - image.real_frame_id = frame_id_map[image.real_frame_id] + image.real_frame = frame_id_map[image.real_frame] images = new_db_images db_data.size = len(images) From e4474737c8348163b71830734c5c534e10da202e Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 23 Sep 2024 16:23:46 +0300 Subject: [PATCH 188/227] Support frame deletion in GT jobs --- cvat/apps/engine/views.py | 87 ++++++++++++++++---- cvat/apps/quality_control/quality_reports.py | 71 ++++++++++++---- 2 files changed, 125 insertions(+), 33 deletions(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 347db217fc6c..0b52c7b367da 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -2098,25 +2098,74 @@ def metadata(self, request, pk): )) ).get(pk=pk) - db_data = db_job.segment.task.data + db_task = db_job.segment.task + db_data = db_task.data start_frame = db_job.segment.start_frame stop_frame = db_job.segment.stop_frame frame_step = db_data.get_frame_step() data_start_frame = db_data.start_frame + start_frame * frame_step data_stop_frame = min(db_data.stop_frame, db_data.start_frame + stop_frame * frame_step) - frame_set = db_job.segment.frame_set + segment_frame_set = db_job.segment.frame_set if request.method == 'PATCH': serializer = DataMetaWriteSerializer(instance=db_data, data=request.data) - if serializer.is_valid(raise_exception=True): - serializer.validated_data['deleted_frames'] = list(filter( - lambda frame: frame >= start_frame and frame <= stop_frame, - serializer.validated_data['deleted_frames'] - )) + list(filter( - lambda frame: frame < start_frame or frame > stop_frame, - db_data.deleted_frames, - )) - db_data = serializer.save() + serializer.is_valid(raise_exception=True) + + deleted_frames = serializer.validated_data.get('deleted_frames') + if deleted_frames is not None: + updated_deleted_frames = [ + f + for f in deleted_frames + if f in segment_frame_set + ] + updated_validation_frames = None + updated_task_frames = None + + if db_job.type == models.JobType.GROUND_TRUTH: + updated_validation_frames = updated_deleted_frames + [ + f + for f in db_data.validation_layout.disabled_frames + if f not in segment_frame_set + ] + + if db_data.validation_layout.mode == models.ValidationMode.GT_POOL: + # GT pool owns its frames, so we exclude them from the task + # Them and the related honeypots in jobs + task_frame_provider = TaskFrameProvider(db_task) + updated_validation_abs_frame_set = set( + map(task_frame_provider.get_abs_frame_number, updated_validation_frames) + ) + excluded_placeholder_frames = [ + task_frame_provider.get_rel_frame_number(frame) + for frame, real_frame in ( + models.Image.objects + .filter(data=db_data, is_placeholder=True) + .values_list('frame', 'real_frame') + .iterator(chunk_size=10000) + ) + if real_frame in updated_validation_abs_frame_set + ] + updated_task_frames = updated_deleted_frames + excluded_placeholder_frames + elif db_data.validation_layout.mode == models.ValidationMode.GT: + # Regular GT jobs only refer to the task frames, without data ownership + pass + else: + assert False + else: + updated_task_frames = updated_deleted_frames + [ + f + for f in db_data.deleted_frames + if f not in segment_frame_set + ] + + if updated_validation_frames is not None: + db_data.validation_layout.disabled_frames = updated_validation_frames + db_data.validation_layout.save(update_fields=['disabled_frames']) + + if updated_task_frames is not None: + db_data.deleted_frames = updated_task_frames + db_data.save(update_fields=['deleted_frames']) + db_job.segment.task.touch() if db_job.segment.task.project: db_job.segment.task.project.touch() @@ -2127,7 +2176,7 @@ def metadata(self, request, pk): media = [ # Insert placeholders if frames are skipped # We could skip them here too, but UI can't decode chunks then - f if f.frame in frame_set else SimpleNamespace( + f if f.frame in segment_frame_set else SimpleNamespace( path=f'placeholder.jpg', width=f.width, height=f.height ) for f in db_data.images.filter( @@ -2136,15 +2185,19 @@ def metadata(self, request, pk): ).all() ] + deleted_frames = set(db_data.deleted_frames) + if db_job.type == models.JobType.GROUND_TRUTH: + deleted_frames.update(db_data.validation_layout.disabled_frames) + # Filter data with segment size - # Should data.size also be cropped by segment size? - db_data.deleted_frames = filter( + db_data.deleted_frames = sorted(filter( lambda frame: frame >= start_frame and frame <= stop_frame, - db_data.deleted_frames, - ) + deleted_frames, + )) + db_data.start_frame = data_start_frame db_data.stop_frame = data_stop_frame - db_data.size = len(frame_set) + db_data.size = len(segment_frame_set) db_data.included_frames = db_job.segment.frames or None frame_meta = [{ diff --git a/cvat/apps/quality_control/quality_reports.py b/cvat/apps/quality_control/quality_reports.py index a62e46f52ccd..d8a037f269ec 100644 --- a/cvat/apps/quality_control/quality_reports.py +++ b/cvat/apps/quality_control/quality_reports.py @@ -10,7 +10,18 @@ from copy import deepcopy from datetime import timedelta from functools import cached_property, partial -from typing import Any, Callable, Dict, Hashable, List, Optional, Sequence, Tuple, Union, cast +from typing import ( + Any, + Callable, + Dict, + Hashable, + List, + Optional, + Sequence, + Tuple, + Union, + cast, +) from uuid import uuid4 import datumaro as dm @@ -35,8 +46,10 @@ from cvat.apps.dataset_manager.task import JobAnnotation from cvat.apps.dataset_manager.util import bulk_create from cvat.apps.engine import serializers as engine_serializers +from cvat.apps.engine.frame_provider import TaskFrameProvider from cvat.apps.engine.models import ( DimensionType, + Image, Job, JobType, ShapeType, @@ -44,6 +57,8 @@ StatusChoice, Task, User, + ValidationLayout, + ValidationMode, ) from cvat.apps.profiler import silk_profile from cvat.apps.quality_control import models @@ -1681,10 +1696,15 @@ def __init__( self.comparator = _Comparator(self._gt_dataset.categories(), settings=settings) - self.included_frames = gt_data_provider.job_data._db_job.segment.frame_set + def _dm_item_to_frame_id(self, item: dm.DatasetItem, dataset: dm.Dataset) -> int: + if dataset is self._ds_dataset: + source_data_provider = self._ds_data_provider + elif dataset is self._gt_dataset: + source_data_provider = self._gt_data_provider + else: + assert False - def _dm_item_to_frame_id(self, item: dm.DatasetItem) -> int: - return self._gt_data_provider.dm_item_id_to_frame_id(item) + return source_data_provider.dm_item_id_to_frame_id(item) def _dm_ann_to_ann_id(self, ann: dm.Annotation, dataset: dm.Dataset): if dataset is self._ds_dataset: @@ -1707,10 +1727,10 @@ def _find_gt_conflicts(self): self._process_frame(ds_item, gt_item) - def _process_frame(self, ds_item, gt_item): - frame_id = self._dm_item_to_frame_id(ds_item) - if self.included_frames is not None and frame_id not in self.included_frames: - return + def _process_frame( + self, ds_item: dm.DatasetItem, gt_item: dm.DatasetItem + ) -> List[AnnotationConflict]: + frame_id = self._dm_item_to_frame_id(ds_item, self._ds_dataset) frame_results = self.comparator.match_annotations(gt_item, ds_item) self._frame_results.setdefault(frame_id, {}) @@ -2218,10 +2238,16 @@ def _check_task_quality(cls, *, task_id: int) -> int: def _compute_reports(self, task_id: int) -> int: with transaction.atomic(): - # The task could have been deleted during scheduling try: + # Preload all the data for the computations. + # It must be done atomically and before all the computations, + # because the task and jobs can be changed after the beginning, + # which will lead to inconsistent results + # TODO: check performance of select_for_update(), + # maybe make several fetching attempts if received data is outdated task = Task.objects.select_related("data").get(id=task_id) except Task.DoesNotExist: + # The task could have been deleted during scheduling return # Try to use a shared queryset to minimize DB requests @@ -2248,17 +2274,30 @@ def _compute_reports(self, task_id: int) -> int: for job in job_queryset: job.segment.task = gt_job.segment.task - # Preload all the data for the computations - # It must be done in a single transaction and before all the remaining computations - # because the task and jobs can be changed after the beginning, - # which will lead to inconsistent results - gt_job_data_provider = JobDataProvider(gt_job.id, queryset=job_queryset) - gt_job_frames = gt_job_data_provider.job_data.get_included_frames() + validation_layout = task.data.validation_layout + task_frame_provider = TaskFrameProvider(task) + gt_job_data_provider = JobDataProvider( + gt_job.id, queryset=job_queryset, included_frames=active_validation_frames + ) + active_validation_frames = gt_job_data_provider.job_data.get_included_frames() + + if validation_layout.mode == ValidationMode.GT_POOL: + active_validation_frames = set( + task_frame_provider.get_rel_frame_number(frame) + for frame, real_frame in ( + Image.objects.filter(data=task.data, is_placeholder=True) + .values_list("frame", "real_frame") + .iterator(chunk_size=10000) + ) + if real_frame in active_validation_frames + ) jobs: List[Job] = [j for j in job_queryset if j.type == JobType.ANNOTATION] job_data_providers = { job.id: JobDataProvider( - job.id, queryset=job_queryset, included_frames=gt_job_frames + job.id, + queryset=job_queryset, + included_frames=set(job.segment.frame_set) & active_validation_frames, ) for job in jobs } From 7aaccbd9c97f5712a3e0b10d9417da0e9984a774 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 23 Sep 2024 16:24:52 +0300 Subject: [PATCH 189/227] Update ui support for frame removal --- .../top-bar/player-navigation.tsx | 6 +--- .../annotation-page/top-bar/top-bar.tsx | 3 -- .../task-quality/allocation-table.tsx | 33 +++++++++++++++---- .../annotation-page/top-bar/top-bar.tsx | 9 ++--- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx index f1a2e9cf2892..aab97f3a2870 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx @@ -31,7 +31,6 @@ interface Props { frameNumber: number; frameFilename: string; frameDeleted: boolean; - deleteFrameAvailable: boolean; deleteFrameShortcut: string; focusFrameInputShortcut: string; inputFrameRef: React.RefObject; @@ -77,7 +76,6 @@ function PlayerNavigation(props: Props): JSX.Element { ranges, keyMap, workspace, - deleteFrameAvailable, onSliderChange, onInputChange, onURLIconClick, @@ -192,9 +190,7 @@ function PlayerNavigation(props: Props): JSX.Element { - { - deleteFrameAvailable && deleteFrameIcon - } + { deleteFrameIcon } diff --git a/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx b/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx index 7af232e3332e..589862d45118 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx @@ -43,7 +43,6 @@ interface Props { focusFrameInputShortcut: string; activeControl: ActiveControl; toolsBlockerState: ToolsBlockerState; - deleteFrameAvailable: boolean; annotationFilters: object[]; initialOpenGuide: boolean; keyMap: KeyMap; @@ -103,7 +102,6 @@ export default function AnnotationTopBarComponent(props: Props): JSX.Element { toolsBlockerState, annotationFilters, initialOpenGuide, - deleteFrameAvailable, navigationType, jobInstance, keyMap, @@ -188,7 +186,6 @@ export default function AnnotationTopBarComponent(props: Props): JSX.Element { onDeleteFrame={onDeleteFrame} onRestoreFrame={onRestoreFrame} switchNavigationBlocked={switchNavigationBlocked} - deleteFrameAvailable={deleteFrameAvailable} /> ), 10]); 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 183d8a1c0e60..49dfab7d5620 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 @@ -15,6 +15,7 @@ import { RestoreIcon } from 'icons'; import { Task, Job, FramesMetaData } from 'cvat-core-wrapper'; import CVATTooltip from 'components/common/cvat-tooltip'; import { sorter } from 'utils/quality'; +import { range } from 'lodash'; interface Props { task: Task; @@ -58,12 +59,32 @@ function AllocationTableComponent(props: Readonly): JSX.Element { getFrameNumber(dataFrameID, dataStartFrame, gtJobMeta.frameStep) )); - return jobFrameNumbers.map((frameID: number, index: number) => ({ - key: frameID, - frame: frameID, - name: gtJobMeta.frames[index]?.name ?? gtJobMeta.frames[0].name, - active: !(frameID in gtJobMeta.deletedFrames), - })); + const jobDataSegmentFrameNumbers = range( + gtJobMeta.startFrame, gtJobMeta.stopFrame + 1, gtJobMeta.frameStep, + ); + + let includedIndex = 0; + const result: any[] = []; + 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; })(); const columns = [ 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 c74a9dea7dcb..b508011adcf6 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 @@ -539,12 +539,8 @@ class AnnotationTopBarContainer extends React.PureComponent { }; private onDeleteFrame = (): void => { - const { - deleteFrame, frameNumber, jobInstance, - } = this.props; - if (jobInstance.type !== JobType.GROUND_TRUTH) { - deleteFrame(frameNumber); - } + const { deleteFrame, frameNumber } = this.props; + deleteFrame(frameNumber); }; private onRestoreFrame = (): void => { @@ -726,7 +722,6 @@ class AnnotationTopBarContainer extends React.PureComponent { toolsBlockerState={toolsBlockerState} jobInstance={jobInstance} activeControl={activeControl} - deleteFrameAvailable={jobInstance.type !== JobType.GROUND_TRUTH} /> ); } From 4667650c8cbbf09a3bd068937a7a455da614c68c Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 23 Sep 2024 16:25:15 +0300 Subject: [PATCH 190/227] Update test assets --- tests/python/shared/assets/cvat_db/data.json | 334 +++++++++---------- 1 file changed, 167 insertions(+), 167 deletions(-) diff --git a/tests/python/shared/assets/cvat_db/data.json b/tests/python/shared/assets/cvat_db/data.json index f1530849c43e..55db8087d66a 100644 --- a/tests/python/shared/assets/cvat_db/data.json +++ b/tests/python/shared/assets/cvat_db/data.json @@ -1628,7 +1628,7 @@ "width": 940, "height": 805, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1641,7 +1641,7 @@ "width": 693, "height": 357, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1654,7 +1654,7 @@ "width": 254, "height": 301, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1667,7 +1667,7 @@ "width": 918, "height": 334, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1680,7 +1680,7 @@ "width": 619, "height": 115, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1693,7 +1693,7 @@ "width": 599, "height": 738, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1706,7 +1706,7 @@ "width": 306, "height": 355, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1719,7 +1719,7 @@ "width": 838, "height": 507, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1732,7 +1732,7 @@ "width": 885, "height": 211, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1745,7 +1745,7 @@ "width": 553, "height": 522, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1758,7 +1758,7 @@ "width": 424, "height": 826, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1771,7 +1771,7 @@ "width": 264, "height": 984, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1784,7 +1784,7 @@ "width": 698, "height": 387, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1797,7 +1797,7 @@ "width": 781, "height": 901, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1810,7 +1810,7 @@ "width": 144, "height": 149, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1823,7 +1823,7 @@ "width": 989, "height": 131, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1836,7 +1836,7 @@ "width": 661, "height": 328, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1849,7 +1849,7 @@ "width": 333, "height": 811, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1862,7 +1862,7 @@ "width": 292, "height": 497, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1875,7 +1875,7 @@ "width": 886, "height": 238, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1888,7 +1888,7 @@ "width": 759, "height": 179, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1901,7 +1901,7 @@ "width": 769, "height": 746, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1914,7 +1914,7 @@ "width": 749, "height": 833, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1927,7 +1927,7 @@ "width": 100, "height": 1, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1940,7 +1940,7 @@ "width": 827, "height": 983, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1953,7 +1953,7 @@ "width": 467, "height": 547, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1966,7 +1966,7 @@ "width": 598, "height": 202, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1979,7 +1979,7 @@ "width": 449, "height": 276, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -1992,7 +1992,7 @@ "width": 170, "height": 999, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2005,7 +2005,7 @@ "width": 473, "height": 471, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2018,7 +2018,7 @@ "width": 607, "height": 745, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2031,7 +2031,7 @@ "width": 853, "height": 578, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2044,7 +2044,7 @@ "width": 823, "height": 270, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2057,7 +2057,7 @@ "width": 545, "height": 179, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2070,7 +2070,7 @@ "width": 827, "height": 932, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2083,7 +2083,7 @@ "width": 836, "height": 636, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2096,7 +2096,7 @@ "width": 396, "height": 350, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2109,7 +2109,7 @@ "width": 177, "height": 862, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2122,7 +2122,7 @@ "width": 318, "height": 925, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2135,7 +2135,7 @@ "width": 734, "height": 832, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2148,7 +2148,7 @@ "width": 925, "height": 934, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2161,7 +2161,7 @@ "width": 851, "height": 270, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2174,7 +2174,7 @@ "width": 776, "height": 610, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2187,7 +2187,7 @@ "width": 293, "height": 265, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2200,7 +2200,7 @@ "width": 333, "height": 805, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2213,7 +2213,7 @@ "width": 403, "height": 478, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2226,7 +2226,7 @@ "width": 585, "height": 721, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2239,7 +2239,7 @@ "width": 639, "height": 570, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2252,7 +2252,7 @@ "width": 894, "height": 278, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2265,7 +2265,7 @@ "width": 220, "height": 596, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2278,7 +2278,7 @@ "width": 749, "height": 967, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2291,7 +2291,7 @@ "width": 961, "height": 670, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2304,7 +2304,7 @@ "width": 393, "height": 736, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2317,7 +2317,7 @@ "width": 650, "height": 140, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2330,7 +2330,7 @@ "width": 199, "height": 710, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2343,7 +2343,7 @@ "width": 948, "height": 659, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2356,7 +2356,7 @@ "width": 837, "height": 367, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2369,7 +2369,7 @@ "width": 257, "height": 265, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2382,7 +2382,7 @@ "width": 104, "height": 811, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2395,7 +2395,7 @@ "width": 665, "height": 512, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2408,7 +2408,7 @@ "width": 234, "height": 975, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2421,7 +2421,7 @@ "width": 809, "height": 350, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2434,7 +2434,7 @@ "width": 359, "height": 943, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2447,7 +2447,7 @@ "width": 782, "height": 383, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2460,7 +2460,7 @@ "width": 571, "height": 945, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2473,7 +2473,7 @@ "width": 414, "height": 212, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2486,7 +2486,7 @@ "width": 680, "height": 583, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2499,7 +2499,7 @@ "width": 779, "height": 877, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2512,7 +2512,7 @@ "width": 411, "height": 672, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2525,7 +2525,7 @@ "width": 810, "height": 399, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2538,7 +2538,7 @@ "width": 916, "height": 158, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2551,7 +2551,7 @@ "width": 936, "height": 182, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2564,7 +2564,7 @@ "width": 783, "height": 433, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2577,7 +2577,7 @@ "width": 231, "height": 121, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2590,7 +2590,7 @@ "width": 721, "height": 705, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2603,7 +2603,7 @@ "width": 631, "height": 225, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2616,7 +2616,7 @@ "width": 540, "height": 167, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2629,7 +2629,7 @@ "width": 203, "height": 211, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2642,7 +2642,7 @@ "width": 677, "height": 144, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2655,7 +2655,7 @@ "width": 697, "height": 954, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2668,7 +2668,7 @@ "width": 974, "height": 452, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2681,7 +2681,7 @@ "width": 783, "height": 760, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2694,7 +2694,7 @@ "width": 528, "height": 458, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2707,7 +2707,7 @@ "width": 520, "height": 350, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2720,7 +2720,7 @@ "width": 569, "height": 483, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2733,7 +2733,7 @@ "width": 783, "height": 760, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2746,7 +2746,7 @@ "width": 528, "height": 458, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2759,7 +2759,7 @@ "width": 520, "height": 350, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2772,7 +2772,7 @@ "width": 569, "height": 483, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2785,7 +2785,7 @@ "width": 514, "height": 935, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2798,7 +2798,7 @@ "width": 502, "height": 705, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2811,7 +2811,7 @@ "width": 541, "height": 825, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2824,7 +2824,7 @@ "width": 883, "height": 208, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2837,7 +2837,7 @@ "width": 974, "height": 452, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2850,7 +2850,7 @@ "width": 783, "height": 760, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2863,7 +2863,7 @@ "width": 528, "height": 458, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2876,7 +2876,7 @@ "width": 520, "height": 350, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2889,7 +2889,7 @@ "width": 569, "height": 483, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2902,7 +2902,7 @@ "width": 607, "height": 668, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2915,7 +2915,7 @@ "width": 483, "height": 483, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2928,7 +2928,7 @@ "width": 982, "height": 376, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2941,7 +2941,7 @@ "width": 565, "height": 365, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2954,7 +2954,7 @@ "width": 339, "height": 351, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2967,7 +2967,7 @@ "width": 944, "height": 271, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2980,7 +2980,7 @@ "width": 865, "height": 401, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -2993,7 +2993,7 @@ "width": 912, "height": 346, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3006,7 +3006,7 @@ "width": 681, "height": 460, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3019,7 +3019,7 @@ "width": 844, "height": 192, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3032,7 +3032,7 @@ "width": 462, "height": 252, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3045,7 +3045,7 @@ "width": 191, "height": 376, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3058,7 +3058,7 @@ "width": 333, "height": 257, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3071,7 +3071,7 @@ "width": 474, "height": 619, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3084,7 +3084,7 @@ "width": 809, "height": 543, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3097,7 +3097,7 @@ "width": 993, "height": 151, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3110,7 +3110,7 @@ "width": 810, "height": 399, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3123,7 +3123,7 @@ "width": 916, "height": 158, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3136,7 +3136,7 @@ "width": 936, "height": 182, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3149,7 +3149,7 @@ "width": 783, "height": 433, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3162,7 +3162,7 @@ "width": 231, "height": 121, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3175,7 +3175,7 @@ "width": 721, "height": 705, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3188,7 +3188,7 @@ "width": 631, "height": 225, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3201,7 +3201,7 @@ "width": 540, "height": 167, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3214,7 +3214,7 @@ "width": 203, "height": 211, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3227,7 +3227,7 @@ "width": 677, "height": 144, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3240,7 +3240,7 @@ "width": 697, "height": 954, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3253,7 +3253,7 @@ "width": 549, "height": 360, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3266,7 +3266,7 @@ "width": 172, "height": 230, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3279,7 +3279,7 @@ "width": 936, "height": 820, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3292,7 +3292,7 @@ "width": 145, "height": 735, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3305,7 +3305,7 @@ "width": 318, "height": 729, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3318,7 +3318,7 @@ "width": 387, "height": 168, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3331,7 +3331,7 @@ "width": 395, "height": 401, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3344,7 +3344,7 @@ "width": 293, "height": 443, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3357,7 +3357,7 @@ "width": 500, "height": 276, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3370,7 +3370,7 @@ "width": 309, "height": 162, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3383,7 +3383,7 @@ "width": 134, "height": 452, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3396,7 +3396,7 @@ "width": 10, "height": 10, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3409,7 +3409,7 @@ "width": 10, "height": 10, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3422,7 +3422,7 @@ "width": 55, "height": 36, "is_placeholder": true, - "real_frame_id": 23 + "real_frame": 23 } }, { @@ -3435,7 +3435,7 @@ "width": 43, "height": 64, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3448,7 +3448,7 @@ "width": 91, "height": 97, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3461,7 +3461,7 @@ "width": 76, "height": 71, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3474,7 +3474,7 @@ "width": 60, "height": 100, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3487,7 +3487,7 @@ "width": 55, "height": 72, "is_placeholder": true, - "real_frame_id": 24 + "real_frame": 24 } }, { @@ -3500,7 +3500,7 @@ "width": 60, "height": 59, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3513,7 +3513,7 @@ "width": 70, "height": 53, "is_placeholder": true, - "real_frame_id": 26 + "real_frame": 26 } }, { @@ -3526,7 +3526,7 @@ "width": 55, "height": 36, "is_placeholder": true, - "real_frame_id": 23 + "real_frame": 23 } }, { @@ -3539,7 +3539,7 @@ "width": 70, "height": 53, "is_placeholder": true, - "real_frame_id": 26 + "real_frame": 26 } }, { @@ -3552,7 +3552,7 @@ "width": 66, "height": 96, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3565,7 +3565,7 @@ "width": 54, "height": 89, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3578,7 +3578,7 @@ "width": 45, "height": 92, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3591,7 +3591,7 @@ "width": 76, "height": 52, "is_placeholder": true, - "real_frame_id": 25 + "real_frame": 25 } }, { @@ -3604,7 +3604,7 @@ "width": 65, "height": 57, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3617,7 +3617,7 @@ "width": 31, "height": 41, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3630,7 +3630,7 @@ "width": 55, "height": 72, "is_placeholder": true, - "real_frame_id": 24 + "real_frame": 24 } }, { @@ -3643,7 +3643,7 @@ "width": 96, "height": 58, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3656,7 +3656,7 @@ "width": 54, "height": 63, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3669,7 +3669,7 @@ "width": 91, "height": 100, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3682,7 +3682,7 @@ "width": 60, "height": 32, "is_placeholder": true, - "real_frame_id": 28 + "real_frame": 28 } }, { @@ -3695,7 +3695,7 @@ "width": 97, "height": 88, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3708,7 +3708,7 @@ "width": 76, "height": 52, "is_placeholder": true, - "real_frame_id": 25 + "real_frame": 25 } }, { @@ -3721,7 +3721,7 @@ "width": 55, "height": 36, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3734,7 +3734,7 @@ "width": 55, "height": 72, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3747,7 +3747,7 @@ "width": 76, "height": 52, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3760,7 +3760,7 @@ "width": 70, "height": 53, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3773,7 +3773,7 @@ "width": 74, "height": 92, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { @@ -3786,7 +3786,7 @@ "width": 60, "height": 32, "is_placeholder": false, - "real_frame_id": 0 + "real_frame": 0 } }, { From dd392919df8c3c7d02a99aaae6425cb99d6531a4 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 23 Sep 2024 16:39:47 +0300 Subject: [PATCH 191/227] Fix linter problems --- cvat/apps/quality_control/quality_reports.py | 22 ++++---------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/cvat/apps/quality_control/quality_reports.py b/cvat/apps/quality_control/quality_reports.py index d8a037f269ec..8e10575abebe 100644 --- a/cvat/apps/quality_control/quality_reports.py +++ b/cvat/apps/quality_control/quality_reports.py @@ -10,18 +10,7 @@ from copy import deepcopy from datetime import timedelta from functools import cached_property, partial -from typing import ( - Any, - Callable, - Dict, - Hashable, - List, - Optional, - Sequence, - Tuple, - Union, - cast, -) +from typing import Any, Callable, Dict, Hashable, List, Optional, Sequence, Tuple, Union, cast from uuid import uuid4 import datumaro as dm @@ -57,7 +46,6 @@ StatusChoice, Task, User, - ValidationLayout, ValidationMode, ) from cvat.apps.profiler import silk_profile @@ -2274,14 +2262,12 @@ def _compute_reports(self, task_id: int) -> int: for job in job_queryset: job.segment.task = gt_job.segment.task - validation_layout = task.data.validation_layout - task_frame_provider = TaskFrameProvider(task) - gt_job_data_provider = JobDataProvider( - gt_job.id, queryset=job_queryset, included_frames=active_validation_frames - ) + gt_job_data_provider = JobDataProvider(gt_job.id, queryset=job_queryset) active_validation_frames = gt_job_data_provider.job_data.get_included_frames() + validation_layout = task.data.validation_layout if validation_layout.mode == ValidationMode.GT_POOL: + task_frame_provider = TaskFrameProvider(task) active_validation_frames = set( task_frame_provider.get_rel_frame_number(frame) for frame, real_frame in ( From cc5637a8eb8239b703545d547465b87dd55c3436 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 24 Sep 2024 16:35:00 +0300 Subject: [PATCH 192/227] Enable job metadata updates in tasks with gt job and honeypots --- cvat-sdk/cvat_sdk/core/proxies/jobs.py | 6 +- cvat/apps/engine/serializers.py | 79 ++++++++++++++ cvat/apps/engine/tests/test_rest_api.py | 20 +++- cvat/apps/engine/views.py | 119 +++++++------------- cvat/schema.yml | 12 ++- tests/python/rest_api/test_jobs.py | 6 +- tests/python/rest_api/test_tasks.py | 137 +++++++++++++++++++----- 7 files changed, 263 insertions(+), 116 deletions(-) diff --git a/cvat-sdk/cvat_sdk/core/proxies/jobs.py b/cvat-sdk/cvat_sdk/core/proxies/jobs.py index 974090f149aa..ac81380b7566 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/jobs.py +++ b/cvat-sdk/cvat_sdk/core/proxies/jobs.py @@ -134,9 +134,11 @@ def get_frames_info(self) -> List[models.IFrameMeta]: return self.get_meta().frames def remove_frames_by_ids(self, ids: Sequence[int]) -> None: - self._client.api_client.tasks_api.jobs_partial_update_data_meta( + self.api.partial_update_data_meta( self.id, - patched_data_meta_write_request=models.PatchedDataMetaWriteRequest(deleted_frames=ids), + patched_job_data_meta_write_request=models.PatchedJobDataMetaWriteRequest( + deleted_frames=ids + ), ) def get_issues(self) -> List[Issue]: diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index c06ec9d6a996..923870727899 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1836,6 +1836,85 @@ class Meta: model = models.Data fields = ('deleted_frames',) +class JobDataMetaWriteSerializer(serializers.ModelSerializer): + deleted_frames = serializers.ListField(child=serializers.IntegerField(min_value=0)) + + class Meta: + model = models.Job + fields = ('deleted_frames',) + + @transaction.atomic + def update(self, instance: models.Job, validated_data: dict[str, Any]) -> models.Job: + db_segment = instance.segment + db_task = db_segment.task + db_data = db_task.data + + deleted_frames = validated_data.get('deleted_frames') + + task_frame_provider = TaskFrameProvider(db_task) + segment_rel_frame_set = set( + map(task_frame_provider.get_rel_frame_number, db_segment.frame_set) + ) + + unknown_deleted_frames = set(deleted_frames) - segment_rel_frame_set + if unknown_deleted_frames: + raise serializers.ValidationError("Frames {} do not belong to the job".format( + format_list(list(map(str, unknown_deleted_frames))) + )) + + updated_validation_frames = None + updated_task_frames = None + + if instance.type == models.JobType.GROUND_TRUTH: + updated_validation_frames = deleted_frames + [ + f + for f in db_data.validation_layout.disabled_frames + if f not in segment_rel_frame_set + ] + + if db_data.validation_layout.mode == models.ValidationMode.GT_POOL: + # GT pool owns its frames, so we exclude them from the task + # Them and the related honeypots in jobs + updated_validation_abs_frame_set = set( + map(task_frame_provider.get_abs_frame_number, updated_validation_frames) + ) + excluded_placeholder_frames = [ + task_frame_provider.get_rel_frame_number(frame) + for frame, real_frame in ( + models.Image.objects + .filter(data=db_data, is_placeholder=True) + .values_list('frame', 'real_frame') + .iterator(chunk_size=10000) + ) + if real_frame in updated_validation_abs_frame_set + ] + updated_task_frames = deleted_frames + excluded_placeholder_frames + elif db_data.validation_layout.mode == models.ValidationMode.GT: + # Regular GT jobs only refer to the task frames, without data ownership + pass + else: + assert False + else: + updated_task_frames = deleted_frames + [ + f + for f in db_data.deleted_frames + if f not in segment_rel_frame_set + ] + + if updated_validation_frames is not None: + db_data.validation_layout.disabled_frames = updated_validation_frames + db_data.validation_layout.save(update_fields=['disabled_frames']) + + if updated_task_frames is not None: + db_data.deleted_frames = updated_task_frames + db_data.save(update_fields=['deleted_frames']) + + db_task.touch() + if db_task.project: + db_task.project.touch() + + return instance + class AttributeValSerializer(serializers.Serializer): spec_id = serializers.IntegerField() value = serializers.CharField(max_length=4096, allow_blank=True) diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index e7ae8ae9ba7b..e3ccd8cfecfa 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -86,6 +86,12 @@ def create_db_task(data): } db_data = Data.objects.create(**data_settings) + + if db_data.stop_frame == 0: + frame_step = int((db_data.frame_filter or 'step=1').split('=')[-1]) + db_data.stop_frame = db_data.start_frame + (db_data.size - 1) * frame_step + db_data.save() + shutil.rmtree(db_data.get_data_dirname(), ignore_errors=True) os.makedirs(db_data.get_data_dirname()) os.makedirs(db_data.get_upload_dirname()) @@ -435,16 +441,24 @@ def _check_response(self, response, db_data, data): def _check_api_v1_jobs_data_meta_id(self, user, data): response = self._run_api_v1_jobs_data_meta_id(self.job.id, user, data) + if user is None: self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - elif user == self.job.segment.task.owner or user == self.job.segment.task.assignee or user == self.job.assignee or user.is_superuser: + elif ( + user == self.job.segment.task.owner or + user == self.job.segment.task.assignee or + user == self.job.assignee or + user.is_superuser + ): self._check_response(response, self.job.segment.task.data, data) else: self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_api_v1_jobss_data_meta(self): + def test_api_v1_jobs_data_meta(self): data = { - "deleted_frames": [1,2,3] + "deleted_frames": list( + range(self.job.segment.start_frame, self.job.segment.stop_frame + 1) + ) } self._check_api_v1_jobs_data_meta_id(self.admin, data) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 094c05342a6a..08b4a0beffa1 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -73,8 +73,8 @@ ) from cvat.apps.engine.serializers import ( AboutSerializer, AnnotationFileSerializer, BasicUserSerializer, - DataMetaReadSerializer, DataMetaWriteSerializer, DataSerializer, - FileInfoSerializer, JobReadSerializer, JobWriteSerializer, LabelSerializer, + DataMetaReadSerializer, DataMetaWriteSerializer, DataSerializer, FileInfoSerializer, + JobDataMetaWriteSerializer, JobReadSerializer, JobWriteSerializer, LabelSerializer, LabeledDataSerializer, ProjectReadSerializer, ProjectWriteSerializer, RqStatusSerializer, TaskReadSerializer, TaskWriteSerializer, @@ -2082,107 +2082,64 @@ def data(self, request, pk): '200': DataMetaReadSerializer, }) @extend_schema(methods=['PATCH'], summary='Update metainformation for media files in a job', - request=DataMetaWriteSerializer, + request=JobDataMetaWriteSerializer, responses={ '200': DataMetaReadSerializer, - }, tags=['tasks'], versions=['2.0']) + }, versions=['2.0']) @action(detail=True, methods=['GET', 'PATCH'], serializer_class=DataMetaReadSerializer, url_path='data/meta') def metadata(self, request, pk): self.get_object() # force call of check_object_permissions() - db_job = models.Job.objects.prefetch_related( + + db_job = models.Job.objects.select_related( 'segment', 'segment__task', - Prefetch('segment__task__data', queryset=models.Data.objects.select_related('video').prefetch_related( - Prefetch('images', queryset=models.Image.objects.prefetch_related('related_files').order_by('frame')) - )) + ).prefetch_related( + Prefetch( + 'segment__task__data', + queryset=models.Data.objects.select_related( + 'video', + 'validation_layout', + ).prefetch_related( + Prefetch( + 'images', + queryset=( + models.Image.objects + .prefetch_related('related_files') + .order_by('frame') + ) + ) + ) + ) ).get(pk=pk) - db_task = db_job.segment.task + if request.method == 'PATCH': + serializer = JobDataMetaWriteSerializer(instance=db_job, data=request.data) + serializer.is_valid(raise_exception=True) + db_job = serializer.save() + + db_segment = db_job.segment + db_task = db_segment.task db_data = db_task.data - start_frame = db_job.segment.start_frame - stop_frame = db_job.segment.stop_frame + start_frame = db_segment.start_frame + stop_frame = db_segment.stop_frame frame_step = db_data.get_frame_step() data_start_frame = db_data.start_frame + start_frame * frame_step data_stop_frame = min(db_data.stop_frame, db_data.start_frame + stop_frame * frame_step) - segment_frame_set = db_job.segment.frame_set - - if request.method == 'PATCH': - serializer = DataMetaWriteSerializer(instance=db_data, data=request.data) - serializer.is_valid(raise_exception=True) - - deleted_frames = serializer.validated_data.get('deleted_frames') - if deleted_frames is not None: - updated_deleted_frames = [ - f - for f in deleted_frames - if f in segment_frame_set - ] - updated_validation_frames = None - updated_task_frames = None - - if db_job.type == models.JobType.GROUND_TRUTH: - updated_validation_frames = updated_deleted_frames + [ - f - for f in db_data.validation_layout.disabled_frames - if f not in segment_frame_set - ] - - if db_data.validation_layout.mode == models.ValidationMode.GT_POOL: - # GT pool owns its frames, so we exclude them from the task - # Them and the related honeypots in jobs - task_frame_provider = TaskFrameProvider(db_task) - updated_validation_abs_frame_set = set( - map(task_frame_provider.get_abs_frame_number, updated_validation_frames) - ) - excluded_placeholder_frames = [ - task_frame_provider.get_rel_frame_number(frame) - for frame, real_frame in ( - models.Image.objects - .filter(data=db_data, is_placeholder=True) - .values_list('frame', 'real_frame') - .iterator(chunk_size=10000) - ) - if real_frame in updated_validation_abs_frame_set - ] - updated_task_frames = updated_deleted_frames + excluded_placeholder_frames - elif db_data.validation_layout.mode == models.ValidationMode.GT: - # Regular GT jobs only refer to the task frames, without data ownership - pass - else: - assert False - else: - updated_task_frames = updated_deleted_frames + [ - f - for f in db_data.deleted_frames - if f not in segment_frame_set - ] - - if updated_validation_frames is not None: - db_data.validation_layout.disabled_frames = updated_validation_frames - db_data.validation_layout.save(update_fields=['disabled_frames']) - - if updated_task_frames is not None: - db_data.deleted_frames = updated_task_frames - db_data.save(update_fields=['deleted_frames']) - - db_job.segment.task.touch() - if db_job.segment.task.project: - db_job.segment.task.project.touch() + segment_frame_set = db_segment.frame_set if hasattr(db_data, 'video'): media = [db_data.video] else: media = [ # Insert placeholders if frames are skipped - # We could skip them here too, but UI can't decode chunks then + # TODO: remove placeholders, UI supports chunks without placeholders already + # after https://github.com/cvat-ai/cvat/pull/8272 f if f.frame in segment_frame_set else SimpleNamespace( path=f'placeholder.jpg', width=f.width, height=f.height ) - for f in db_data.images.filter( - frame__gte=data_start_frame, - frame__lte=data_stop_frame, - ).all() + for f in db_data.images.all() + if f.frame in range(data_start_frame, data_stop_frame + frame_step, frame_step) ] deleted_frames = set(db_data.deleted_frames) @@ -2198,7 +2155,7 @@ def metadata(self, request, pk): db_data.start_frame = data_start_frame db_data.stop_frame = data_stop_frame db_data.size = len(segment_frame_set) - db_data.included_frames = db_job.segment.frames or None + db_data.included_frames = db_segment.frames or None frame_meta = [{ 'width': item.width, diff --git a/cvat/schema.yml b/cvat/schema.yml index e8db401351bf..3cd632f11d3b 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -2404,12 +2404,12 @@ paths: description: A unique integer value identifying this job. required: true tags: - - tasks + - jobs requestBody: content: application/json: schema: - $ref: '#/components/schemas/PatchedDataMetaWriteRequest' + $ref: '#/components/schemas/PatchedJobDataMetaWriteRequest' security: - sessionAuth: [] csrfAuth: [] @@ -9243,6 +9243,14 @@ components: nullable: true resolved: type: boolean + PatchedJobDataMetaWriteRequest: + type: object + properties: + deleted_frames: + type: array + items: + type: integer + minimum: 0 PatchedJobWriteRequest: type: object properties: diff --git a/tests/python/rest_api/test_jobs.py b/tests/python/rest_api/test_jobs.py index 03204c4702ed..9490f4d100ce 100644 --- a/tests/python/rest_api/test_jobs.py +++ b/tests/python/rest_api/test_jobs.py @@ -709,7 +709,7 @@ def test_can_get_gt_job_meta(self, admin_user, tasks, jobs, task_mode, request): task_id = task["id"] with make_api_client(user) as api_client: (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) - frame_step = int(task_meta.frame_filter.split("=")[-1]) if task_meta.frame_filter else 1 + frame_step = parse_frame_step(task_meta.frame_filter.split("=")[-1]) job_frame_ids = list(range(task_meta.start_frame, task_meta.stop_frame, frame_step))[ :job_frame_count @@ -815,7 +815,7 @@ def test_can_get_gt_job_chunk( task_id = task["id"] with make_api_client(user) as api_client: (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) - frame_step = int(task_meta.frame_filter.split("=")[-1]) if task_meta.frame_filter else 1 + frame_step = parse_frame_step(task_meta.frame_filter.split("=")[-1]) task_frame_ids = range(task_meta.start_frame, task_meta.stop_frame + 1, frame_step) rng = np.random.Generator(np.random.MT19937(42)) @@ -898,7 +898,7 @@ def test_can_get_gt_job_frame(self, admin_user, tasks, jobs, task_mode, quality, task_id = task["id"] with make_api_client(user) as api_client: (task_meta, _) = api_client.tasks_api.retrieve_data_meta(task_id) - frame_step = int(task_meta.frame_filter.split("=")[-1]) if task_meta.frame_filter else 1 + frame_step = parse_frame_step(task_meta.frame_filter.split("=")[-1]) job_frame_ids = list(range(task_meta.start_frame, task_meta.stop_frame, frame_step))[ :job_frame_count diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index fe14b941ee0f..f0cd51b02453 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -3573,10 +3573,11 @@ def _test_can_restore_task_from_backup(self, task_id: int): @pytest.mark.usefixtures("restore_db_per_function") -class TestWorkWithGtJobs: - def test_gt_job_annotations_are_not_present_in_task_annotation_export_with_normal_gt_job( - self, tmp_path, admin_user, tasks, jobs, job_has_annotations - ): +class TestWorkWithSimpleGtJobTasks: + @fixture + def fxt_task_with_gt_job( + self, tasks, jobs, job_has_annotations + ) -> Generator[Dict[str, Any], None, None]: gt_job = next( j for j in jobs @@ -3585,13 +3586,23 @@ def test_gt_job_annotations_are_not_present_in_task_annotation_export_with_norma if tasks[j["task_id"]]["validation_mode"] == "gt" if tasks[j["task_id"]]["size"] ) + task = tasks[gt_job["task_id"]] - task_jobs = [j for j in jobs if j["task_id"] == task["id"]] + annotation_jobs = sorted( + [j for j in jobs if j["task_id"] == task["id"] if j["id"] != gt_job["id"]], + key=lambda j: j["start_frame"], + ) + + yield task, gt_job, annotation_jobs + + @parametrize("task, gt_job, annotation_jobs", [fixture_ref(fxt_task_with_gt_job)]) + def test_gt_job_annotations_are_not_present_in_task_annotation_export( + self, tmp_path, admin_user, task, gt_job, annotation_jobs + ): with make_sdk_client(admin_user) as client: - for j in task_jobs: - if j["type"] != "ground_truth": - client.jobs.retrieve(j["id"]).remove_annotations() + for j in annotation_jobs: + client.jobs.retrieve(j["id"]).remove_annotations() task_obj = client.tasks.retrieve(task["id"]) task_raw_annotations = task_obj.get_annotations() @@ -3618,6 +3629,44 @@ def test_gt_job_annotations_are_not_present_in_task_annotation_export_with_norma assert not annotation_source.shapes assert not annotation_source.tracks + @parametrize("task, gt_job, annotation_jobs", [fixture_ref(fxt_task_with_gt_job)]) + def test_can_exclude_and_restore_gt_frames_via_job_meta( + self, admin_user, task, gt_job, annotation_jobs + ): + with make_api_client(admin_user) as api_client: + task_meta, _ = api_client.tasks_api.retrieve_data_meta(task["id"]) + gt_job_meta, _ = api_client.jobs_api.retrieve_data_meta(gt_job["id"]) + frame_step = parse_frame_step(task_meta.frame_filter) + + for deleted_gt_frames in [ + [i] + for i in range(gt_job_meta["start_frame"], gt_job["stop_frame"] + 1) + if gt_job_meta.start_frame + i * frame_step in gt_job_meta.included_frames + ] + [[]]: + updated_gt_job_meta, _ = api_client.jobs_api.partial_update_data_meta( + gt_job["id"], + patched_job_data_meta_write_request=models.PatchedJobDataMetaWriteRequest( + deleted_frames=deleted_gt_frames + ), + ) + + assert updated_gt_job_meta.deleted_frames == deleted_gt_frames + + # the excluded GT frames must be excluded only from the GT job + updated_task_meta, _ = api_client.tasks_api.retrieve_data_meta(task["id"]) + assert task_meta.deleted_frames == updated_task_meta.deleted_frames + + for j in annotation_jobs: + updated_job_meta, _ = api_client.jobs_api.retrieve_data_meta(j["id"]) + assert [ + i + for i in updated_task_meta.deleted_frames + if j["start_frame"] <= i <= j["stop_frame"] + ] == updated_job_meta.deleted_frames + + +@pytest.mark.usefixtures("restore_db_per_function") +class TestWorkWithHoneypotTasks: @fixture def fxt_task_with_honeypots( self, tasks, jobs, job_has_annotations @@ -3630,18 +3679,23 @@ def fxt_task_with_honeypots( if tasks[j["task_id"]]["validation_mode"] == "gt_pool" if tasks[j["task_id"]]["size"] ) - yield tasks[gt_job["task_id"]], gt_job - @parametrize("task, gt_job", [fixture_ref(fxt_task_with_honeypots)]) - def test_gt_job_annotations_are_present_in_task_annotation_export_in_task_with_honeypots( - self, tmp_path, admin_user, jobs, task, gt_job - ): - task_jobs = [j for j in jobs if j["task_id"] == task["id"]] + task = tasks[gt_job["task_id"]] + + annotation_jobs = sorted( + [j for j in jobs if j["task_id"] == task["id"] if j["id"] != gt_job["id"]], + key=lambda j: j["start_frame"], + ) + yield task, gt_job, annotation_jobs + + @parametrize("task, gt_job, annotation_jobs", [fixture_ref(fxt_task_with_honeypots)]) + def test_gt_job_annotations_are_present_in_task_annotation_export( + self, tmp_path, admin_user, task, gt_job, annotation_jobs + ): with make_sdk_client(admin_user) as client: - for j in task_jobs: - if j["type"] != "ground_truth": - client.jobs.retrieve(j["id"]).remove_annotations() + for j in annotation_jobs: + client.jobs.retrieve(j["id"]).remove_annotations() task_obj = client.tasks.retrieve(task["id"]) task_raw_annotations = json.loads(task_obj.api.retrieve_annotations(task["id"])[1].data) @@ -3679,17 +3733,14 @@ def test_gt_job_annotations_are_present_in_task_annotation_export_in_task_with_h assert compare_annotations(task_raw_annotations, task_dataset_file_annotations) == {} assert compare_annotations(task_raw_annotations, task_annotations_file_annotations) == {} - @parametrize("task, gt_job", [fixture_ref(fxt_task_with_honeypots)]) + @parametrize("task, gt_job, annotation_jobs", [fixture_ref(fxt_task_with_honeypots)]) @pytest.mark.parametrize("dataset_format", ["CVAT for images 1.1", "Datumaro 1.0"]) - def test_placeholder_frames_are_not_present_in_task_annotation_export_in_task_with_honeypots( - self, tmp_path, admin_user, jobs, task, gt_job, dataset_format + def test_placeholder_frames_are_not_present_in_task_annotation_export( + self, tmp_path, admin_user, task, gt_job, annotation_jobs, dataset_format ): - task_jobs = [j for j in jobs if j["task_id"] == task["id"]] - with make_sdk_client(admin_user) as client: - for j in task_jobs: - if j["type"] != "ground_truth": - client.jobs.retrieve(j["id"]).remove_annotations() + for j in annotation_jobs: + client.jobs.retrieve(j["id"]).remove_annotations() task_obj = client.tasks.retrieve(task["id"]) @@ -3726,6 +3777,42 @@ def test_placeholder_frames_are_not_present_in_task_annotation_export_in_task_wi else: assert False + @parametrize("task, gt_job, annotation_jobs", [fixture_ref(fxt_task_with_honeypots)]) + def test_can_exclude_and_restore_gt_frames_via_job_meta( + self, admin_user, task, gt_job, annotation_jobs + ): + with make_api_client(admin_user) as api_client: + task_meta, _ = api_client.tasks_api.retrieve_data_meta(task["id"]) + task_frames = [f.name for f in task_meta.frames] + + for deleted_gt_frames in [ + [v] for v in range(gt_job["start_frame"], gt_job["stop_frame"] + 1) + ] + [[]]: + updated_gt_job_meta, _ = api_client.jobs_api.partial_update_data_meta( + gt_job["id"], + patched_job_data_meta_write_request=models.PatchedDataMetaWriteRequest( + deleted_frames=deleted_gt_frames + ), + ) + + assert updated_gt_job_meta.deleted_frames == deleted_gt_frames + + # the excluded GT frames must be excluded from all the jobs with the same frame + deleted_frame_names = [task_frames[i] for i in deleted_gt_frames] + updated_task_meta, _ = api_client.tasks_api.retrieve_data_meta(task["id"]) + assert ( + sorted(i for i, f in enumerate(task_frames) if f in deleted_frame_names) + == updated_task_meta.deleted_frames + ) + + for j in annotation_jobs: + updated_job_meta, _ = api_client.jobs_api.retrieve_data_meta(j["id"]) + assert [ + i + for i in updated_task_meta.deleted_frames + if j["start_frame"] <= i <= j["stop_frame"] + ] == updated_job_meta.deleted_frames + @pytest.mark.usefixtures("restore_db_per_class") class TestGetTaskPreview: From 7535d0901e75a3533f5cfc08deec677b9b94a666 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 24 Sep 2024 21:21:26 +0300 Subject: [PATCH 193/227] Fix test --- tests/python/rest_api/test_tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index f0cd51b02453..ca53570e2ab9 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -3790,7 +3790,7 @@ def test_can_exclude_and_restore_gt_frames_via_job_meta( ] + [[]]: updated_gt_job_meta, _ = api_client.jobs_api.partial_update_data_meta( gt_job["id"], - patched_job_data_meta_write_request=models.PatchedDataMetaWriteRequest( + patched_job_data_meta_write_request=models.PatchedJobDataMetaWriteRequest( deleted_frames=deleted_gt_frames ), ) From 1910dcd719aa1c349cd6d2d3b74497570b376991 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 25 Sep 2024 13:59:38 +0300 Subject: [PATCH 194/227] Add tests for gt frame exclusion --- cvat/apps/engine/serializers.py | 15 +++ cvat/apps/engine/views.py | 4 +- tests/python/rest_api/test_quality_control.py | 106 +++++++++++++++++- tests/python/rest_api/test_tasks.py | 105 ++++++++++++++++- 4 files changed, 224 insertions(+), 6 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 923870727899..7cf0af8bac88 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1836,6 +1836,21 @@ class Meta: model = models.Data fields = ('deleted_frames',) + def update(self, instance: models.Data, validated_data: dict[str, Any]) -> models.Data: + deleted_frames = validated_data['deleted_frames'] + validation_layout = getattr(instance, 'validation_layout', None) + if validation_layout and validation_layout.mode == models.ValidationMode.GT_POOL: + gt_frame_set = set(validation_layout.frames) + changed_deleted_frames = set(deleted_frames).difference(instance.deleted_frames) + if not gt_frame_set.isdisjoint(changed_deleted_frames): + raise serializers.ValidationError( + f"When task validation mode is {models.ValidationMode.GT_POOL}, " + "GT frames can only be deleted and restored via the " + "GT job's api/jobs/\{id\}/data/meta endpoint" + ) + + return super().update(instance, validated_data) + class JobDataMetaWriteSerializer(serializers.ModelSerializer): deleted_frames = serializers.ListField(child=serializers.IntegerField(min_value=0)) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 08b4a0beffa1..388cf5e2f689 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -1576,8 +1576,8 @@ def metadata(self, request, pk): if request.method == 'PATCH': serializer = DataMetaWriteSerializer(instance=db_task.data, data=request.data) - if serializer.is_valid(raise_exception=True): - db_task.data = serializer.save() + serializer.is_valid(raise_exception=True) + db_task.data = serializer.save() if hasattr(db_task.data, 'video'): media = [db_task.data.video] diff --git a/tests/python/rest_api/test_quality_control.py b/tests/python/rest_api/test_quality_control.py index 6775bfe7eaff..3a9a2928f4b5 100644 --- a/tests/python/rest_api/test_quality_control.py +++ b/tests/python/rest_api/test_quality_control.py @@ -5,6 +5,7 @@ import json from copy import deepcopy from http import HTTPStatus +from itertools import groupby from typing import Any, Dict, List, Optional, Tuple import pytest @@ -15,7 +16,7 @@ from shared.utils.config import make_api_client -from .utils import CollectionSimpleFilterTestBase +from .utils import CollectionSimpleFilterTestBase, parse_frame_step class _PermissionTestBase: @@ -1360,3 +1361,106 @@ def test_can_compute_quality_if_non_skeleton_label_follows_skeleton_label( with make_api_client(admin_user) as api_client: (_, response) = api_client.quality_api.retrieve_report_data(report["id"]) assert response.status == HTTPStatus.OK + + @pytest.mark.parametrize("task_id", [26]) + def test_excluded_gt_job_frames_are_not_included_in_honeypot_task_quality_report( + self, admin_user, task_id: int, jobs + ): + gt_job = next(j for j in jobs if j["task_id"] == task_id if j["type"] == "ground_truth") + gt_job_frames = range(gt_job["start_frame"], gt_job["stop_frame"] + 1) + + with make_api_client(admin_user) as api_client: + gt_job_meta, _ = api_client.jobs_api.retrieve_data_meta(gt_job["id"]) + gt_frame_names = [f.name for f in gt_job_meta.frames] + + task_meta, _ = api_client.tasks_api.retrieve_data_meta(task_id) + honeypot_frames = [ + i + for i, f in enumerate(task_meta.frames) + if f.name in gt_frame_names and i not in gt_job_frames + ] + gt_frame_uses = { + name: (gt_job["start_frame"] + gt_frame_names.index(name), list(ids)) + for name, ids in groupby( + sorted( + [ + i + for i in range(task_meta.size) + if task_meta.frames[i].name in gt_frame_names + ], + key=lambda i: task_meta.frames[i].name, + ), + key=lambda i: task_meta.frames[i].name, + ) + } + + api_client.jobs_api.partial_update( + gt_job["id"], + patched_job_write_request=models.PatchedJobWriteRequest( + stage="acceptance", state="completed" + ), + ) + report = self.create_quality_report(admin_user, task_id) + + (_, response) = api_client.quality_api.retrieve_report_data(report["id"]) + assert response.status == HTTPStatus.OK + assert honeypot_frames == json.loads(response.data)["comparison_summary"]["frames"] + + excluded_gt_frame, excluded_gt_frame_honeypots = next( + (i, honeypots) for i, honeypots in gt_frame_uses.values() if len(honeypots) > 1 + ) + api_client.jobs_api.partial_update_data_meta( + gt_job["id"], + patched_job_data_meta_write_request=models.PatchedJobDataMetaWriteRequest( + deleted_frames=[excluded_gt_frame] + ), + ) + + report = self.create_quality_report(admin_user, task_id) + + (_, response) = api_client.quality_api.retrieve_report_data(report["id"]) + assert response.status == HTTPStatus.OK + assert [ + v for v in honeypot_frames if v not in excluded_gt_frame_honeypots + ] == json.loads(response.data)["comparison_summary"]["frames"] + + @pytest.mark.parametrize("task_id", [23]) + def test_excluded_gt_job_frames_are_not_included_in_simple_gt_job_task_quality_report( + self, admin_user, task_id: int, jobs + ): + gt_job = next(j for j in jobs if j["task_id"] == task_id if j["type"] == "ground_truth") + + with make_api_client(admin_user) as api_client: + gt_job_meta, _ = api_client.jobs_api.retrieve_data_meta(gt_job["id"]) + gt_frames = [ + (f - gt_job_meta.start_frame) // parse_frame_step(gt_job_meta.frame_filter) + for f in gt_job_meta.included_frames + ] + + api_client.jobs_api.partial_update( + gt_job["id"], + patched_job_write_request=models.PatchedJobWriteRequest( + stage="acceptance", state="completed" + ), + ) + report = self.create_quality_report(admin_user, task_id) + + (_, response) = api_client.quality_api.retrieve_report_data(report["id"]) + assert response.status == HTTPStatus.OK + assert gt_frames == json.loads(response.data)["comparison_summary"]["frames"] + + excluded_gt_frame = gt_frames[0] + api_client.jobs_api.partial_update_data_meta( + gt_job["id"], + patched_job_data_meta_write_request=models.PatchedJobDataMetaWriteRequest( + deleted_frames=[excluded_gt_frame] + ), + ) + + report = self.create_quality_report(admin_user, task_id) + + (_, response) = api_client.quality_api.retrieve_report_data(report["id"]) + assert response.status == HTTPStatus.OK + assert [f for f in gt_frames if f != excluded_gt_frame] == json.loads(response.data)[ + "comparison_summary" + ]["frames"] diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index ca53570e2ab9..525b0c4cd0be 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -3664,6 +3664,38 @@ def test_can_exclude_and_restore_gt_frames_via_job_meta( if j["start_frame"] <= i <= j["stop_frame"] ] == updated_job_meta.deleted_frames + @parametrize("task, gt_job, annotation_jobs", [fixture_ref(fxt_task_with_gt_job)]) + def test_can_delete_gt_frames_by_changing_job_meta_in_owning_annotation_job( + self, admin_user, task, gt_job, annotation_jobs + ): + with make_api_client(admin_user) as api_client: + task_meta, _ = api_client.tasks_api.retrieve_data_meta(task["id"]) + gt_job_meta, _ = api_client.jobs_api.retrieve_data_meta(gt_job["id"]) + frame_step = parse_frame_step(task_meta.frame_filter) + + gt_frames = [ + (f - gt_job_meta.start_frame) // frame_step for f in gt_job_meta.included_frames + ] + deleted_gt_frame = gt_frames[0] + + annotation_job = next( + j + for j in annotation_jobs + if j["start_frame"] <= deleted_gt_frame <= j["stop_frame"] + ) + api_client.jobs_api.partial_update_data_meta( + annotation_job["id"], + patched_job_data_meta_write_request=models.PatchedJobDataMetaWriteRequest( + deleted_frames=[deleted_gt_frame] + ), + ) + + updated_gt_job_meta, _ = api_client.jobs_api.retrieve_data_meta(gt_job["id"]) + assert updated_gt_job_meta.deleted_frames == [deleted_gt_frame] + + updated_task_meta, _ = api_client.tasks_api.retrieve_data_meta(task["id"]) + assert task_meta.deleted_frames == updated_task_meta.deleted_frames + @pytest.mark.usefixtures("restore_db_per_function") class TestWorkWithHoneypotTasks: @@ -3750,14 +3782,14 @@ def test_placeholder_frames_are_not_present_in_task_annotation_export( task_meta = task_obj.get_meta() task_frame_names = [frame.name for frame in task_meta.frames] - validation_frame_ids = range(gt_job["start_frame"], gt_job["stop_frame"] + 1) - validation_frame_names = [task_frame_names[i] for i in validation_frame_ids] + gt_frame_ids = range(gt_job["start_frame"], gt_job["stop_frame"] + 1) + gt_frame_names = [task_frame_names[i] for i in gt_frame_ids] frame_step = parse_frame_step(task_meta.frame_filter) expected_frames = [ (task_meta.start_frame + frame * frame_step, name) for frame, name in enumerate(task_frame_names) - if frame in validation_frame_ids or name not in validation_frame_names + if frame in gt_frame_ids or name not in gt_frame_names ] with zipfile.ZipFile(dataset_file, "r") as archive: @@ -3813,6 +3845,73 @@ def test_can_exclude_and_restore_gt_frames_via_job_meta( if j["start_frame"] <= i <= j["stop_frame"] ] == updated_job_meta.deleted_frames + @parametrize("task, gt_job, annotation_jobs", [fixture_ref(fxt_task_with_honeypots)]) + def test_can_delete_honeypot_frames_by_changing_job_meta_in_annotation_job( + self, admin_user, task, gt_job, annotation_jobs + ): + with make_api_client(admin_user) as api_client: + task_meta, _ = api_client.tasks_api.retrieve_data_meta(task["id"]) + + task_frame_names = [frame.name for frame in task_meta.frames] + gt_frame_ids = range(gt_job["start_frame"], gt_job["stop_frame"] + 1) + gt_frame_names = [task_frame_names[i] for i in gt_frame_ids] + + honeypot_frame_ids = [ + i for i, f in enumerate(task_meta.frames) if f.name in gt_frame_names + ] + deleted_honeypot_frame = honeypot_frame_ids[0] + + annotation_job_with_honeypot = next( + j + for j in annotation_jobs + if j["start_frame"] <= deleted_honeypot_frame <= j["stop_frame"] + ) + api_client.jobs_api.partial_update_data_meta( + annotation_job_with_honeypot["id"], + patched_job_data_meta_write_request=models.PatchedJobDataMetaWriteRequest( + deleted_frames=[deleted_honeypot_frame] + ), + ) + + updated_gt_job_meta, _ = api_client.jobs_api.retrieve_data_meta(gt_job["id"]) + assert updated_gt_job_meta.deleted_frames == [] # must not be affected + + updated_task_meta, _ = api_client.tasks_api.retrieve_data_meta(task["id"]) + assert updated_task_meta.deleted_frames == [deleted_honeypot_frame] # must be affected + + @parametrize("task, gt_job, annotation_jobs", [fixture_ref(fxt_task_with_honeypots)]) + def test_can_restore_gt_frames_via_task_meta_only_if_all_frames_are_restored( + self, admin_user, task, gt_job, annotation_jobs + ): + assert gt_job["stop_frame"] - gt_job["start_frame"] + 1 >= 2 + + with make_api_client(admin_user) as api_client: + api_client.jobs_api.partial_update_data_meta( + gt_job["id"], + patched_job_data_meta_write_request=models.PatchedJobDataMetaWriteRequest( + deleted_frames=[gt_job["start_frame"]] + ), + ) + + _, response = api_client.tasks_api.partial_update_data_meta( + task["id"], + patched_data_meta_write_request=models.PatchedDataMetaWriteRequest( + deleted_frames=[gt_job["start_frame"], gt_job["start_frame"] + 1] + ), + _parse_response=False, + _check_status=False, + ) + assert response.status == HTTPStatus.BAD_REQUEST + assert b"GT frames can only be deleted" in response.data + + updated_task_meta, _ = api_client.tasks_api.partial_update_data_meta( + task["id"], + patched_data_meta_write_request=models.PatchedDataMetaWriteRequest( + deleted_frames=[] + ), + ) + assert updated_task_meta.deleted_frames == [] + @pytest.mark.usefixtures("restore_db_per_class") class TestGetTaskPreview: From 7be49e2c8e2404e65cd65f5e2e924f37cf2c7a35 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 25 Sep 2024 15:01:14 +0300 Subject: [PATCH 195/227] Fix test --- tests/python/rest_api/test_tasks.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 525b0c4cd0be..5ae70fddb70e 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -3630,7 +3630,7 @@ def test_gt_job_annotations_are_not_present_in_task_annotation_export( assert not annotation_source.tracks @parametrize("task, gt_job, annotation_jobs", [fixture_ref(fxt_task_with_gt_job)]) - def test_can_exclude_and_restore_gt_frames_via_job_meta( + def test_can_exclude_and_restore_gt_frames_via_gt_job_meta( self, admin_user, task, gt_job, annotation_jobs ): with make_api_client(admin_user) as api_client: @@ -3654,15 +3654,11 @@ def test_can_exclude_and_restore_gt_frames_via_job_meta( # the excluded GT frames must be excluded only from the GT job updated_task_meta, _ = api_client.tasks_api.retrieve_data_meta(task["id"]) - assert task_meta.deleted_frames == updated_task_meta.deleted_frames + assert updated_task_meta.deleted_frames == [] for j in annotation_jobs: updated_job_meta, _ = api_client.jobs_api.retrieve_data_meta(j["id"]) - assert [ - i - for i in updated_task_meta.deleted_frames - if j["start_frame"] <= i <= j["stop_frame"] - ] == updated_job_meta.deleted_frames + assert updated_job_meta.deleted_frames == [] @parametrize("task, gt_job, annotation_jobs", [fixture_ref(fxt_task_with_gt_job)]) def test_can_delete_gt_frames_by_changing_job_meta_in_owning_annotation_job( @@ -3690,11 +3686,12 @@ def test_can_delete_gt_frames_by_changing_job_meta_in_owning_annotation_job( ), ) + # in this case deleted frames are deleted everywhere updated_gt_job_meta, _ = api_client.jobs_api.retrieve_data_meta(gt_job["id"]) assert updated_gt_job_meta.deleted_frames == [deleted_gt_frame] updated_task_meta, _ = api_client.tasks_api.retrieve_data_meta(task["id"]) - assert task_meta.deleted_frames == updated_task_meta.deleted_frames + assert updated_task_meta.deleted_frames == [deleted_gt_frame] @pytest.mark.usefixtures("restore_db_per_function") From 8f784d916fc94dc48987ce150b23b7de4d4ea0fc Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 25 Sep 2024 15:16:28 +0300 Subject: [PATCH 196/227] Fix linter error --- cvat/apps/engine/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 7cf0af8bac88..716475eac6a2 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1846,7 +1846,7 @@ def update(self, instance: models.Data, validated_data: dict[str, Any]) -> model raise serializers.ValidationError( f"When task validation mode is {models.ValidationMode.GT_POOL}, " "GT frames can only be deleted and restored via the " - "GT job's api/jobs/\{id\}/data/meta endpoint" + "GT job's api/jobs/{id}/data/meta endpoint" ) return super().update(instance, validated_data) From c1eeee2ce33803851ae8621ebe7f1445a0739890 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 27 Sep 2024 19:35:55 +0300 Subject: [PATCH 197/227] Update changelog --- ...20240819_210200_mzhiltso_validation_api.md | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/changelog.d/20240819_210200_mzhiltso_validation_api.md b/changelog.d/20240819_210200_mzhiltso_validation_api.md index 2031da1456ab..bc4c57563258 100644 --- a/changelog.d/20240819_210200_mzhiltso_validation_api.md +++ b/changelog.d/20240819_210200_mzhiltso_validation_api.md @@ -1,4 +1,24 @@ ### Added -- Last assignee update date in quality reports, new options in quality settings - () +- New task mode: Honeypots (GT pool) + () +- New task creation options for quality control: Honeypots (GT pool), GT job + () +- New GT job frame selection method: `random_per_job`, + which guarantees each job will have GT overlap + () +- \[Server API\] POST `/jobs/`: new frame selection parameters, + which accept percentages, instead of absolute values + () +- \[Server API\] GET `/api/tasks/{id}/` got a new `validation_mode` field, + reflecting the current validation configuration (immutable) + () +- \[Server API\] POST `/api/tasks/{id}/data` got a new `validation_params` field, + which allows to enable `GT` and `GT_POOL` validation for a task on its creation + () + +### Changed + +- \[Server API\] POST `/jobs/` `.frames` field now expects relative frame numbers + instead of absolute (source data) ones + () From 12a3fe0c9073485cc9cb6a133fa5ef15196cd9cb Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 27 Sep 2024 19:37:22 +0300 Subject: [PATCH 198/227] Reduce code duplication on the same check for validation mode --- cvat/apps/dataset_manager/bindings.py | 4 +--- cvat/apps/dataset_manager/task.py | 16 ++++------------ cvat/apps/engine/backup.py | 7 ++----- cvat/apps/engine/models.py | 4 ++++ cvat/apps/engine/serializers.py | 12 +++--------- cvat/apps/engine/views.py | 2 +- 6 files changed, 15 insertions(+), 30 deletions(-) diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 74f2868941cf..172b54abf508 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -937,9 +937,7 @@ def _get_db_images(self): def _init_frame_info(self): super()._init_frame_info() - if hasattr(self.db_data, 'validation_layout') and ( - self.db_data.validation_layout.mode == models.ValidationMode.GT_POOL - ): + if self.db_data.validation_mode == models.ValidationMode.GT_POOL: # For GT pool-enabled tasks, we: # - skip validation frames in normal jobs on annotation export # - load annotations for GT pool frames on annotation import diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 71cac16cf89a..80f6dc76d41a 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -454,9 +454,7 @@ def _validate_input_annotations(self, data: Union[AnnotationIR, dict]) -> Annota db_data = self.db_job.segment.task.data - if data.tracks and hasattr(db_data, 'validation_layout') and ( - db_data.validation_layout.mode == models.ValidationMode.GT_POOL - ): + if data.tracks and db_data.validation.mode == models.ValidationMode.GT_POOL: # Only tags and shapes can be used in tasks with GT pool raise ValidationError("Tracks are not supported when task validation mode is {}".format( models.ValidationMode.GT_POOL @@ -795,9 +793,7 @@ def __init__(self, pk): ).get(id=pk) requested_job_types = [models.JobType.ANNOTATION] - if hasattr(self.db_task.data, 'validation_layout') and ( - self.db_task.data.validation_layout.mode == models.ValidationMode.GT_POOL - ): + if self.db_task.data.validation_mode == models.ValidationMode.GT_POOL: requested_job_types.append(models.JobType.GROUND_TRUTH) self.db_jobs = ( @@ -815,10 +811,7 @@ def _patch_data(self, data: Union[AnnotationIR, dict], action: Optional[PatchAct if not isinstance(data, AnnotationIR): data = AnnotationIR(self.db_task.dimension, data) - if ( - hasattr(self.db_task.data, 'validation_layout') and - self.db_task.data.validation_layout.mode == models.ValidationMode.GT_POOL - ): + if self.db_task.data.validation_mode == models.ValidationMode.GT_POOL: self._preprocess_input_annotations_for_gt_pool_task(data, action=action) splitted_data = {} @@ -940,8 +933,7 @@ def init_from_db(self): for db_job in self.db_jobs: if db_job.type == models.JobType.GROUND_TRUTH and not ( - hasattr(self.db_task.data, 'validation_layout') and - self.db_task.data.validation_layout.mode == models.ValidationMode.GT_POOL + self.db_task.data.validation_mode == models.ValidationMode.GT_POOL ): continue diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index c847df9d4630..78a9a9221afb 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -429,11 +429,8 @@ def serialize_segment(db_segment): segment.update(job_data) if ( - self._db_task.segment_size == 0 and segment_type == models.SegmentType.RANGE or - ( - hasattr(self._db_data, 'validation_layout') and - self._db_data.validation_layout.mode == models.ValidationMode.GT_POOL - ) + self._db_task.segment_size == 0 and segment_type == models.SegmentType.RANGE + or self._db_data.validation_mode == models.ValidationMode.GT_POOL ): segment.update(serialize_segment_file_names(db_segment)) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 171ea600a36e..eb0b8206a997 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -377,6 +377,10 @@ def update_validation_layout( return validation_layout + @property + def validation_mode(self) -> Optional[ValidationMode]: + return getattr(getattr(self, 'validation_layout', None), 'mode', None) + class Video(models.Model): data = models.OneToOneField(Data, on_delete=models.CASCADE, related_name="video", null=True) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 716475eac6a2..3a6cf36bcb29 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -765,15 +765,9 @@ def create(self, validated_data): "Ground Truth jobs can only be added in 2d tasks" ) - if ( - (validation_layout := getattr(task.data, 'validation_layout', None)) and - ( - validation_layout.mode == models.ValidationMode.GT_POOL or - validation_layout.mode == models.ValidationMode.GT - ) - ): + if task.data.validation_mode in (models.ValidationMode.GT_POOL, models.ValidationMode.GT): raise serializers.ValidationError( - f'Task with validation mode "{validation_layout.mode}" ' + f'Task with validation mode "{task.data.validation_mode}" ' 'cannot have more than 1 GT job' ) @@ -1437,7 +1431,7 @@ class TaskReadSerializer(serializers.ModelSerializer): jobs = JobsSummarySerializer(url_filter_key='task_id', source='segment_set') labels = LabelsSummarySerializer(source='*') validation_mode = serializers.CharField( - source='data.validation_layout.mode', required=False, allow_null=True + source='data.validation_mode', required=False, allow_null=True ) class Meta: diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 388cf5e2f689..e1cfe04b2bff 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -1763,7 +1763,7 @@ def perform_destroy(self, instance): raise ValidationError("Only ground truth jobs can be removed") validation_layout: Optional[models.ValidationLayout] = getattr( - instance.segment.task.data, 'validation_layout', None + instance.segment.task.data.validation_mode, None ) if (validation_layout and validation_layout.mode == models.ValidationMode.GT_POOL): raise ValidationError( From 128c78af463dd3907eb13b0e635a7965b9294cbb Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 27 Sep 2024 19:37:45 +0300 Subject: [PATCH 199/227] Fix some other comments --- cvat/apps/dataset_manager/task.py | 6 +++--- cvat/apps/dataset_manager/util.py | 2 +- cvat/apps/engine/backup.py | 2 +- cvat/apps/engine/views.py | 9 +++++++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 80f6dc76d41a..d4dd1782472e 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -857,9 +857,9 @@ def _preprocess_input_annotations_for_gt_pool_task( models.ValidationMode.GT_POOL )) - gt_job = next( - db_job for db_job in self.db_jobs if db_job.type == models.JobType.GROUND_TRUTH - ) + gt_job = self.db_task.gt_job + if gt_job is None: + raise AssertionError(f"Can't find GT job in the task {self.db_task.id}") # Copy GT pool annotations into other jobs, with replacement of any existing annotations gt_pool_frames = gt_job.segment.frame_set diff --git a/cvat/apps/dataset_manager/util.py b/cvat/apps/dataset_manager/util.py index e73c651416fa..0193748446f3 100644 --- a/cvat/apps/dataset_manager/util.py +++ b/cvat/apps/dataset_manager/util.py @@ -89,7 +89,7 @@ def faster_deepcopy(v): if t is dict: return {k: faster_deepcopy(vv) for k, vv in v.items()} elif t in (list, tuple, set): - return type(v)(faster_deepcopy(vv) for vv in v) + return t(faster_deepcopy(vv) for vv in v) elif isinstance(v, (int, float, str, bool)) or v is None: return v else: diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index 78a9a9221afb..499700a3b4ef 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -362,7 +362,7 @@ def _write_data(self, zip_object, target_dir=None): if self._db_data.storage == StorageChoice.LOCAL: data_dir = self._db_data.get_upload_dirname() self._write_directory( - source_dir=self._db_data.get_upload_dirname(), + source_dir=data_dir, zip_object=zip_object, target_dir=target_data_dir, exclude_files=[self.MEDIA_MANIFEST_INDEX_FILENAME] diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index e1cfe04b2bff..a38a06c728f0 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -831,8 +831,13 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, PartialUpdateModelMixin, UploadMixin, DatasetMixin, BackupMixin, CsrfWorkaroundMixin ): queryset = Task.objects.select_related( - 'data', 'assignee', 'owner', - 'target_storage', 'source_storage', 'annotation_guide', + 'data', + 'data__validation_layout', + 'assignee', + 'owner', + 'target_storage', + 'source_storage', + 'annotation_guide', ).prefetch_related( 'segment_set__job_set', 'segment_set__job_set__assignee', From cafdeecb30ec52389fa4d0b39fbd901c4acb607e Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 27 Sep 2024 19:38:36 +0300 Subject: [PATCH 200/227] Update tests/python/rest_api/test_jobs.py --- tests/python/rest_api/test_jobs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/rest_api/test_jobs.py b/tests/python/rest_api/test_jobs.py index 9490f4d100ce..438db4b6455a 100644 --- a/tests/python/rest_api/test_jobs.py +++ b/tests/python/rest_api/test_jobs.py @@ -101,7 +101,7 @@ def _test_create_job_fails( idgen=lambda **args: "-".join([args["frame_selection_method"], *args["method_params"]]), ) @pytest.mark.parametrize("task_mode", ["annotation", "interpolation"]) - def test_can_gt_job_in_a_task( + def test_can_create_gt_job_in_a_task( self, admin_user, tasks, From 89bb815a4a11f376413dc01d73436d02558c0f14 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Fri, 27 Sep 2024 19:39:48 +0300 Subject: [PATCH 201/227] Remove extra file --- cvat/apps/engine/model_utils.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 cvat/apps/engine/model_utils.py diff --git a/cvat/apps/engine/model_utils.py b/cvat/apps/engine/model_utils.py deleted file mode 100644 index 67d20d0b1944..000000000000 --- a/cvat/apps/engine/model_utils.py +++ /dev/null @@ -1,5 +0,0 @@ -# Copyright (C) 2024 CVAT.ai Corporation -# -# SPDX-License-Identifier: MIT - -from __future__ import annotations From 8451fdaf309f4f9e01f35c281b8967e61ae6cb81 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 12:05:05 +0300 Subject: [PATCH 202/227] Fix invalid handling of start, stop frames and frame step in tasks with honeypots --- cvat/apps/dataset_manager/task.py | 28 ++++++++++++++++++---------- cvat/apps/engine/task.py | 3 +++ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index d4dd1782472e..2d2e18c77c53 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -861,19 +861,27 @@ def _preprocess_input_annotations_for_gt_pool_task( if gt_job is None: raise AssertionError(f"Can't find GT job in the task {self.db_task.id}") + db_data = self.db_task.data + frame_step = db_data.get_frame_step() + + def _to_rel_frame(abs_frame: int) -> int: + return (abs_frame - db_data.start_frame) // frame_step + # Copy GT pool annotations into other jobs, with replacement of any existing annotations - gt_pool_frames = gt_job.segment.frame_set - task_validation_frame_groups: dict[int, int] = {} # real_id -> [placeholder_id, ...] - task_validation_frame_ids: set[int] = set() - for frame, real_frame in ( + gt_abs_frame_set = sorted(gt_job.segment.frame_set) + task_gt_honeypots: dict[int, int] = {} # real_id -> [placeholder_id, ...] + task_gt_frames: set[int] = set() + for abs_frame, abs_real_frame in ( self.db_task.data.images - .filter(is_placeholder=True, real_frame__in=gt_pool_frames) + .filter(is_placeholder=True, real_frame__in=gt_abs_frame_set) .values_list('frame', 'real_frame') .iterator(chunk_size=1000) ): - task_validation_frame_ids.add(frame) - task_validation_frame_groups.setdefault(real_frame, []).append(frame) + frame = _to_rel_frame(abs_frame) + task_gt_frames.add(frame) + task_gt_honeypots.setdefault(_to_rel_frame(abs_real_frame), []).append(frame) + gt_pool_frames = tuple(map(_to_rel_frame, gt_abs_frame_set)) assert sorted(gt_pool_frames) == list(range(min(gt_pool_frames), max(gt_pool_frames) + 1)) gt_annotations = data.slice(min(gt_pool_frames), max(gt_pool_frames)) @@ -892,17 +900,17 @@ def _preprocess_input_annotations_for_gt_pool_task( ) task_annotation_manager = AnnotationManager(data, dimension=self.db_task.dimension) - task_annotation_manager.clear_frames(task_validation_frame_ids) + task_annotation_manager.clear_frames(task_gt_frames) for ann_type, gt_annotation in itertools.chain( zip(itertools.repeat('tag'), gt_annotations.tags), zip(itertools.repeat('shape'), gt_annotations.shapes), ): - for placeholder_frame_id in task_validation_frame_groups.get( + for honeypot_frame_id in task_gt_honeypots.get( gt_annotation["frame"], [] # some GT frames may be unused ): copied_annotation = faster_deepcopy(gt_annotation) - copied_annotation["frame"] = placeholder_frame_id + copied_annotation["frame"] = honeypot_frame_id for ann in itertools.chain( [copied_annotation], copied_annotation.get('elements', []) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 71a90df489d3..a933f1287ac3 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1308,6 +1308,9 @@ def _update_status(msg: str) -> None: images = new_db_images db_data.size = len(images) + db_data.start_frame = 0 + db_data.stop_frame = 0 + db_data.frame_filter = '' # Update manifest manifest = ImageManifestManager(db_data.get_manifest_path()) From 3d9fba2b8f76764c339181646fe5e1dbdf639569 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 13:44:32 +0300 Subject: [PATCH 203/227] Update tests --- tests/python/rest_api/test_tasks.py | 46 ++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 5ae70fddb70e..50b7b0fcf009 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -2486,7 +2486,7 @@ class _TaskSpecBase(_TaskSpec): @property def frame_step(self) -> int: - v = getattr(self, "frame_filter", "step=1") + v = getattr(self, "frame_filter", None) or "step=1" return int(v.split("=")[-1]) def __getattr__(self, k: str) -> Any: @@ -2617,9 +2617,12 @@ def fxt_uploaded_images_task_with_segments_start_stop_step( step=step, ) - @pytest.fixture(scope="class") - def fxt_uploaded_images_task_with_segments_and_honeypots( - self, request: pytest.FixtureRequest + def _uploaded_images_task_with_honeypots_and_segments_base( + self, + request: pytest.FixtureRequest, + *, + start_frame: Optional[int] = None, + step: Optional[int] = None, ) -> Generator[Tuple[_TaskSpec, int], None, None]: validation_params = models.DataRequestValidationParams._from_openapi_data( mode="gt_pool", @@ -2629,9 +2632,10 @@ def fxt_uploaded_images_task_with_segments_and_honeypots( frames_per_job_count=2, ) + used_frames_count = 15 + total_frame_count = (start_frame or 0) + used_frames_count * (step or 1) base_segment_size = 4 - total_frame_count = 15 - regular_frame_count = 15 - validation_params.frame_count + regular_frame_count = used_frames_count - validation_params.frame_count final_segment_size = base_segment_size + validation_params.frames_per_job_count final_task_size = ( regular_frame_count @@ -2649,6 +2653,8 @@ def fxt_uploaded_images_task_with_segments_and_honeypots( image_files=image_files, segment_size=base_segment_size, sorting_method="random", + start_frame=start_frame, + step=step, validation_params=validation_params, ) ) as task_gen: @@ -2667,8 +2673,30 @@ def fxt_uploaded_images_task_with_segments_and_honeypots( task_spec.size = final_task_size task_spec._params.segment_size = final_segment_size + # These parameters are not applicable to the resulting task, + # they are only effective during task creation + if start_frame or step: + task_spec._data_params.start_frame = 0 + task_spec._data_params.stop_frame = task_spec.size + task_spec._data_params.frame_filter = "" + yield task_spec, task_id + @fixture(scope="class") + def fxt_uploaded_images_task_with_honeypots_and_segments( + self, request: pytest.FixtureRequest + ) -> Generator[Tuple[_TaskSpec, int], None, None]: + yield from self._uploaded_images_task_with_honeypots_and_segments_base(request) + + @fixture(scope="class") + @parametrize("start_frame, step", [(2, 3)]) + def fxt_uploaded_images_task_with_honeypots_and_segments_start_step( + self, request: pytest.FixtureRequest, start_frame: Optional[int], step: Optional[int] + ) -> Generator[Tuple[_TaskSpec, int], None, None]: + yield from self._uploaded_images_task_with_honeypots_and_segments_base( + request, start_frame=start_frame, step=step + ) + def _uploaded_video_task_fxt_base( self, request: pytest.FixtureRequest, @@ -2767,9 +2795,13 @@ def _compare_images( assert np.array_equal(chunk_frame_pixels, expected_pixels) _tasks_with_honeypots_cases = [ - fixture_ref("fxt_uploaded_images_task_with_segments_and_honeypots"), + fixture_ref("fxt_uploaded_images_task_with_honeypots_and_segments"), + fixture_ref("fxt_uploaded_images_task_with_honeypots_and_segments_start_step"), ] + # Keep in mind that these fixtures are generated eagerly + # (before each depending test or group of tests), + # e.g. a failing task creation in one the fixtures will fail all the depending tests cases. _all_task_cases = [ fixture_ref("fxt_uploaded_images_task"), fixture_ref("fxt_uploaded_images_task_with_segments"), From 4b2b2dd1a9e12d01c33ae6b76fca9c4bf75ef581 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 13:45:59 +0300 Subject: [PATCH 204/227] Fix field access --- cvat/apps/dataset_manager/task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 2d2e18c77c53..9de68905d604 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -454,7 +454,7 @@ def _validate_input_annotations(self, data: Union[AnnotationIR, dict]) -> Annota db_data = self.db_job.segment.task.data - if data.tracks and db_data.validation.mode == models.ValidationMode.GT_POOL: + if data.tracks and db_data.validation_mode == models.ValidationMode.GT_POOL: # Only tags and shapes can be used in tasks with GT pool raise ValidationError("Tracks are not supported when task validation mode is {}".format( models.ValidationMode.GT_POOL From 8460d5d0f9388bab90dd78e0686dd1956646664f Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 14:26:42 +0300 Subject: [PATCH 205/227] Extract allocation table contents building function --- .../task-quality/allocation-table.tsx | 93 ++++++++++--------- 1 file changed, 50 insertions(+), 43 deletions(-) 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 eb0ce5889c90..0c53b282b2e7 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 @@ -33,6 +33,55 @@ interface RowData { active: boolean; } +interface TableRowData extends RowData { + key: Key; +} + +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 + + function getDataStartFrame(meta: FramesMetaData, localStartFrame: number): number { + return meta.startFrame - localStartFrame * meta.frameStep; + } + + function getFrameNumber(dataFrameNumber: number, dataStartFrame: number, step: number): number { + return (dataFrameNumber - dataStartFrame) / step; + } + + const dataStartFrame = getDataStartFrame(gtJobMeta, gtJob.startFrame); + const jobFrameNumbers = gtJobMeta.getDataFrameNumbers().map((dataFrameID: number) => ( + getFrameNumber(dataFrameID, dataStartFrame, gtJobMeta.frameStep) + )); + + 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, @@ -45,49 +94,7 @@ function AllocationTable(props: Readonly): JSX.Element { selectedRows: [], }); - const data = (() => { - // A workaround for meta frames using source data numbers - // TODO: remove once meta is migrated to relative frame numbers - function getDataStartFrame(meta: FramesMetaData, localStartFrame: number): number { - return meta.startFrame - localStartFrame * meta.frameStep; - } - - function getFrameNumber(dataFrameNumber: number, dataStartFrame: number, step: number): number { - return (dataFrameNumber - dataStartFrame) / step; - } - - const dataStartFrame = getDataStartFrame(gtJobMeta, gtJob.startFrame); - const jobFrameNumbers = gtJobMeta.getDataFrameNumbers().map((dataFrameID: number) => ( - getFrameNumber(dataFrameID, dataStartFrame, gtJobMeta.frameStep) - )); - - const jobDataSegmentFrameNumbers = range( - gtJobMeta.startFrame, gtJobMeta.stopFrame + 1, gtJobMeta.frameStep, - ); - - let includedIndex = 0; - const result: any[] = []; - 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; - })(); + const data = getAllocationTableContents(gtJobMeta, gtJob); const columns = [ { From a1bb8817b5d5e1bd5befc7a65ba25f12bee8cf87 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 14:35:27 +0300 Subject: [PATCH 206/227] Fix job removal --- cvat/apps/engine/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index a38a06c728f0..e4156c0ad30a 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -1768,7 +1768,7 @@ def perform_destroy(self, instance): raise ValidationError("Only ground truth jobs can be removed") validation_layout: Optional[models.ValidationLayout] = getattr( - instance.segment.task.data.validation_mode, None + instance.segment.task.data, 'validation_layout', None ) if (validation_layout and validation_layout.mode == models.ValidationMode.GT_POOL): raise ValidationError( From a4b8a9770fbfe5fa70cec14c90e806c7bcd598de Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 16:43:22 +0300 Subject: [PATCH 207/227] Fix static chunk creation --- cvat/apps/engine/media_extractors.py | 13 +++++++++++++ cvat/apps/engine/task.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index bb8c97c70b99..9c1d2deca189 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -259,6 +259,19 @@ def _get_preview(obj): def get_image_size(self, i): pass + @property + def start(self) -> int: + return self._start + + @property + def stop(self) -> Optional[int]: + return self._stop + + @property + def step(self) -> int: + return self._step + + class ImageListReader(IMediaReader): def __init__(self, source_path, diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index a933f1287ac3..46d3a4d4f832 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1623,7 +1623,7 @@ def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: ( # Convert absolute to relative ids (extractor output positions) # Extractor will skip frames outside requested - (abs_frame_id - db_data.start_frame) // frame_step + (abs_frame_id - media_extractor.start) // media_extractor.step for abs_frame_id in ( frame_map.get(frame, frame) for frame in db_segment.frame_set From e9e00b0e88f22c107ecb8daf72452a17c0694efa Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 16:43:36 +0300 Subject: [PATCH 208/227] Add more test cases --- tests/python/rest_api/test_tasks.py | 41 ++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 29d3495bedf5..f5f37dbc0c25 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -2704,6 +2704,9 @@ def _uploaded_video_task_fxt_base( *, frame_count: int = 10, segment_size: Optional[int] = None, + start_frame: Optional[int] = None, + stop_frame: Optional[int] = None, + step: Optional[int] = None, ) -> Generator[Tuple[_VideoTaskSpec, int], None, None]: task_params = { "name": f"{request.node.name}[{request.fixturename}]", @@ -2719,6 +2722,15 @@ def _uploaded_video_task_fxt_base( "client_files": [video_file], } + if start_frame is not None: + data_params["start_frame"] = start_frame + + if stop_frame is not None: + data_params["stop_frame"] = stop_frame + + if step is not None: + data_params["frame_filter"] = f"step={step}" + def get_video_file() -> io.BytesIO: return io.BytesIO(video_data) @@ -2727,7 +2739,7 @@ def get_video_file() -> io.BytesIO: models.TaskWriteRequest._from_openapi_data(**task_params), models.DataRequest._from_openapi_data(**data_params), get_video_file=get_video_file, - size=frame_count, + size=len(range(start_frame or 0, (stop_frame or frame_count - 1) + 1, step or 1)), ), task_id @pytest.fixture(scope="class") @@ -2743,6 +2755,22 @@ def fxt_uploaded_video_task_with_segments( ) -> Generator[Tuple[_TaskSpec, int], None, None]: yield from self._uploaded_video_task_fxt_base(request=request, segment_size=4) + @fixture(scope="class") + @parametrize("step", [2, 5]) + @parametrize("stop_frame", [15, 26]) + @parametrize("start_frame", [3, 7]) + def fxt_uploaded_video_task_with_segments_start_stop_step( + self, request: pytest.FixtureRequest, start_frame: int, stop_frame: Optional[int], step: int + ) -> Generator[Tuple[_TaskSpec, int], None, None]: + yield from self._uploaded_video_task_fxt_base( + request=request, + frame_count=30, + segment_size=4, + start_frame=start_frame, + stop_frame=stop_frame, + step=step, + ) + def _compute_annotation_segment_params(self, task_spec: _TaskSpec) -> List[Tuple[int, int]]: segment_params = [] frame_step = task_spec.frame_step @@ -2804,11 +2832,12 @@ def _compare_images( # (before each depending test or group of tests), # e.g. a failing task creation in one the fixtures will fail all the depending tests cases. _all_task_cases = [ - fixture_ref("fxt_uploaded_images_task"), - fixture_ref("fxt_uploaded_images_task_with_segments"), - fixture_ref("fxt_uploaded_images_task_with_segments_start_stop_step"), - fixture_ref("fxt_uploaded_video_task"), - fixture_ref("fxt_uploaded_video_task_with_segments"), + # fixture_ref("fxt_uploaded_images_task"), + # fixture_ref("fxt_uploaded_images_task_with_segments"), + # fixture_ref("fxt_uploaded_images_task_with_segments_start_stop_step"), + # fixture_ref("fxt_uploaded_video_task"), + # fixture_ref("fxt_uploaded_video_task_with_segments"), + fixture_ref("fxt_uploaded_video_task_with_segments_start_stop_step"), ] + _tasks_with_honeypots_cases @parametrize("task_spec, task_id", _all_task_cases) From 20d3006a5d58e61cfb1d382fa4134c90f6390ad8 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 16:44:58 +0300 Subject: [PATCH 209/227] Remove unused variable --- cvat/apps/engine/task.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 46d3a4d4f832..2d3b6ca03ef0 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1614,7 +1614,6 @@ def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: media_extractor, MEDIA_TYPES['video']['extractor'] ) else 2 with concurrent.futures.ThreadPoolExecutor(max_workers=max_concurrency) as executor: - frame_step = db_data.get_frame_step() for segment_idx, db_segment in enumerate(db_segments): frame_counter = itertools.count() for chunk_idx, chunk_frame_ids in ( From cc004262dbb79e611daf06e42db38730a5baab1e Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 17:49:40 +0300 Subject: [PATCH 210/227] Make frame set check more reliable --- cvat/apps/dataset_manager/task.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 9de68905d604..5b72f92a1ebc 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -882,7 +882,9 @@ def _to_rel_frame(abs_frame: int) -> int: task_gt_honeypots.setdefault(_to_rel_frame(abs_real_frame), []).append(frame) gt_pool_frames = tuple(map(_to_rel_frame, gt_abs_frame_set)) - assert sorted(gt_pool_frames) == list(range(min(gt_pool_frames), max(gt_pool_frames) + 1)) + if sorted(gt_pool_frames) != list(range(min(gt_pool_frames), max(gt_pool_frames) + 1)): + raise AssertionError("Expected a continuous GT pool frame set") # to be used in slice() + gt_annotations = data.slice(min(gt_pool_frames), max(gt_pool_frames)) if action and not ( From b5ab1be250932bd24a687078e182b9c69ebaaf0f Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 17:57:09 +0300 Subject: [PATCH 211/227] Make segment type check more robust --- cvat/apps/engine/migrations/0084_honeypot_support.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/migrations/0084_honeypot_support.py b/cvat/apps/engine/migrations/0084_honeypot_support.py index f64535003c69..c964de0f7ca3 100644 --- a/cvat/apps/engine/migrations/0084_honeypot_support.py +++ b/cvat/apps/engine/migrations/0084_honeypot_support.py @@ -35,7 +35,7 @@ def get_segment_rel_frame_set(db_segment) -> Collection[int]: elif db_segment.type == "specific_frames": frame_set = set(frame_range).intersection(db_segment.frames or []) else: - assert False + raise ValueError(f"Unknown segment type: {db_segment.type}") return sorted(get_rel_frame(abs_frame, db_data) for abs_frame in frame_set) From 23bfc2c7dcd47d50974200ea27798d066358b098 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 18:01:47 +0300 Subject: [PATCH 212/227] Remove extra db call --- cvat/apps/engine/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index eb0b8206a997..4ea734b19a26 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -373,7 +373,6 @@ def update_validation_layout( validation_layout.save() ValidationParams.objects.filter(task_data_id=self.id).delete() - ValidationFrame.objects.filter(validation_params__task_data_id=self.id).delete() return validation_layout From d4bc318fe82121e3ce61329fa1f1f584c5138e4e Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 18:08:10 +0300 Subject: [PATCH 213/227] Improve error message --- cvat/apps/engine/frame_provider.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 475ada51e743..7f49ec12aacd 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -394,14 +394,24 @@ def _get_segment(self, validated_frame_number: int) -> models.Segment: abs_frame_number = self.get_abs_frame_number(validated_frame_number) - return next( - s - for s in sorted( - self._db_task.segment_set.all(), - key=lambda s: s.type != models.SegmentType.RANGE, # prioritize RANGE segments - ) - if abs_frame_number in s.frame_set + segment = next( + ( + s + for s in sorted( + self._db_task.segment_set.all(), + key=lambda s: s.type != models.SegmentType.RANGE, # prioritize RANGE segments + ) + if abs_frame_number in s.frame_set + ), + None ) + if segment is None: + raise AssertionError( + f"Can't find a segment with frame {validated_frame_number} " + f"in task {self._db_task.id}" + ) + + return segment def _get_segment_frame_provider(self, frame_number: int) -> SegmentFrameProvider: return SegmentFrameProvider(self._get_segment(self.validate_frame_number(frame_number))) From 57f1d71288ffb3019559746edba465c12f0c33ae Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 30 Sep 2024 18:12:33 +0300 Subject: [PATCH 214/227] Add field description in the api --- cvat/apps/engine/serializers.py | 3 ++- cvat/schema.yml | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 3a6cf36bcb29..528a528ef4a9 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1431,7 +1431,8 @@ class TaskReadSerializer(serializers.ModelSerializer): jobs = JobsSummarySerializer(url_filter_key='task_id', source='segment_set') labels = LabelsSummarySerializer(source='*') validation_mode = serializers.CharField( - source='data.validation_mode', required=False, allow_null=True + source='data.validation_mode', required=False, allow_null=True, + help_text="Describes how the task validation is performed. Configured at task creation" ) class Meta: diff --git a/cvat/schema.yml b/cvat/schema.yml index 3cd632f11d3b..dc600a229657 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -10576,6 +10576,8 @@ components: validation_mode: type: string nullable: true + description: Describes how the task validation is performed. Configured + at task creation required: - jobs - labels From 1e8433c8968f043169f12d8bb7da289656b3c428 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 1 Oct 2024 15:51:59 +0300 Subject: [PATCH 215/227] Merge test db with develop --- tests/python/shared/assets/annotations.json | 4701 +++++++++++------ .../shared/assets/cvat_db/cvat_data.tar.bz2 | Bin 86315 -> 89057 bytes tests/python/shared/assets/cvat_db/data.json | 1913 ++++++- tests/python/shared/assets/jobs.json | 138 +- tests/python/shared/assets/labels.json | 24 +- .../shared/assets/quality_settings.json | 21 +- tests/python/shared/assets/tasks.json | 117 +- 7 files changed, 4911 insertions(+), 2003 deletions(-) diff --git a/tests/python/shared/assets/annotations.json b/tests/python/shared/assets/annotations.json index 6c95410ff3ee..39dee6e3279c 100644 --- a/tests/python/shared/assets/annotations.json +++ b/tests/python/shared/assets/annotations.json @@ -4643,25 +4643,28 @@ "tags": [], "tracks": [], "version": 0 - } - }, - "task": { - "2": { + }, + "38": { "shapes": [ { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame1 n1" + } + ], "elements": [], "frame": 0, "group": 0, - "id": 1, - "label_id": 3, + "id": 169, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 223.39453125, - 226.0751953125, - 513.7663269042969, - 377.9619903564453 + 19.650000000003274, + 13.100000000002183, + 31.850000000004002, + 18.900000000001455 ], "rotation": 0.0, "source": "manual", @@ -4669,216 +4672,216 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame2 n1" + } + ], "elements": [], "frame": 1, "group": 0, - "id": 2, - "label_id": 3, + "id": 170, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 63.0791015625, - 139.75390625, - 132.19337349397574, - 112.3867469879533, - 189.71144578313397, - 159.23614457831354, - 191.1030120481937, - 246.9048192771097, - 86.73554216867524, - 335.5012048192784, - 32.00060240964012, - 250.15180722891637 + 18.650000000003274, + 10.500000000001819, + 28.650000000003274, + 15.200000000002547 ], "rotation": 0.0, "source": "manual", - "type": "polygon", + "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame2 n2" + } + ], "elements": [], "frame": 1, "group": 0, - "id": 3, - "label_id": 4, + "id": 171, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 83.0244140625, - 216.75390625, - 112.24759036144678, - 162.48313253012202, - 167.44638554216908, - 183.35662650602535, - 149.35602409638705, - 252.0072289156633, - 84.41626506024113, - 292.8265060240974, - 72.81987951807241, - 258.9650602409638 + 18.850000000002183, + 19.50000000000182, + 27.05000000000291, + 24.900000000001455 ], "rotation": 0.0, "source": "manual", - "type": "polygon", + "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame6 n1" + } + ], "elements": [], - "frame": 2, + "frame": 5, "group": 0, - "id": 4, - "label_id": 3, + "id": 172, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 24.443359375, - 107.2275390625, - 84.91109877913368, - 61.125083240844106, - 169.4316315205324, - 75.1561598224198, - 226.5581576026634, - 113.90865704772477, - 240.5892341842391, - 205.77880133185317, - 210.52264150943483, - 270.9230854605994 + 26.25000000000182, + 16.50000000000182, + 40.95000000000255, + 23.900000000001455 ], "rotation": 0.0, "source": "manual", - "type": "polyline", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "39": { + "shapes": [ + { + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame1 n1" + } + ], + "elements": [], + "frame": 8, + "group": 0, + "id": 173, + "label_id": 77, + "occluded": false, + "outside": false, + "points": [ + 14.650000000003274, + 10.000000000001819, + 25.750000000003638, + 17.30000000000109 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame1 n2" + } + ], "elements": [], - "frame": 22, + "frame": 8, "group": 0, - "id": 5, - "label_id": 3, + "id": 174, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 148.94921875, - 285.6865234375, - 313.515094339622, - 400.32830188679145, - 217.36415094339463, - 585.2339622641503, - 64.81698113207494, - 499.25283018867776 + 30.350000000002183, + 18.700000000002547, + 43.05000000000291, + 26.400000000003274 ], "rotation": 0.0, "source": "manual", - "type": "points", + "type": "rectangle", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "5": { - "shapes": [ + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame2 n1" + } + ], "elements": [], - "frame": 0, + "frame": 9, "group": 0, - "id": 29, - "label_id": 9, + "id": 175, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 364.0361328125, - 528.87890625, - 609.5286041189956, - 586.544622425632, - 835.2494279176244, - 360.0000000000018, - 543.6247139588122, - 175.4691075514893, - 326.9656750572103, - 192.76887871853796, - 244.58581235698148, - 319.63386727689067 + 9.200000000002547, + 34.35000000000218, + 21.900000000003274, + 38.55000000000291 ], "rotation": 0.0, "source": "manual", - "type": "polygon", + "type": "rectangle", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "6": { - "shapes": [], - "tags": [], - "tracks": [], - "version": 0 - }, - "7": { - "shapes": [ + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame2 n2" + } + ], "elements": [], - "frame": 0, + "frame": 9, "group": 0, - "id": 27, - "label_id": 11, + "id": 176, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 448.3779296875, - 356.4892578125, - 438.2558352402775, - 761.3861556064112, - 744.1780320366161, - 319.37356979405195, - 446.1288329519466, - 163.03832951945333 + 40.900390625, + 29.0498046875, + 48.80000000000291, + 30.350000000002183, + 45.10000000000218, + 39.25000000000182, + 45.70000000000255, + 24.450000000002547 ], "rotation": 0.0, "source": "manual", - "type": "polygon", + "type": "points", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "8": { - "shapes": [ + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame5 n1" + } + ], "elements": [], - "frame": 0, + "frame": 12, "group": 0, - "id": 30, - "label_id": 13, + "id": 177, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 440.0439453125, - 84.0791015625, - 71.83311938382576, - 249.81514762516053, - 380.4441591784325, - 526.585365853658, - 677.6251604621302, - 260.42875481386363, - 629.4557124518615, - 127.35044929396645 + 16.791015625, + 32.8505859375, + 27.858705213058784, + 37.01258996859542, + 21.633141273523506, + 39.77950727505595 ], "rotation": 0.0, "source": "manual", - "type": "polygon", + "type": "points", "z_order": 0 } ], @@ -4886,478 +4889,381 @@ "tracks": [], "version": 0 }, - "9": { + "40": { "shapes": [ { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame1 n1" + } + ], "elements": [], - "frame": 0, + "frame": 16, "group": 0, - "id": 31, - "label_id": 6, + "id": 178, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 65.6189987163034, - 100.96585365853753, - 142.12734274711147, - 362.6243902439037 + 29.0498046875, + 14.2998046875, + 30.350000000002183, + 22.00000000000182, + 20.650000000003274, + 21.600000000002183, + 20.650000000003274, + 11.30000000000291 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame2 n1" + } + ], "elements": [], - "frame": 15, + "frame": 17, "group": 0, - "id": 41, - "label_id": 6, + "id": 179, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 53.062929061787145, - 301.6390160183091, - 197.94851258581548, - 763.3266590389048 + 51.2001953125, + 10.900390625, + 56.60000000000218, + 15.700000000002547, + 48.400000000003274, + 20.400000000003274 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 }, { "attributes": [ { - "spec_id": 1, - "value": "mazda" + "spec_id": 15, + "value": "j3 frame5 n1" } ], "elements": [], - "frame": 16, + "frame": 20, "group": 0, - "id": 42, - "label_id": 5, + "id": 180, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 172.0810546875, - 105.990234375, - 285.97262095255974, - 138.40000000000146 + 37.2998046875, + 7.7001953125, + 42.400000000003274, + 11.900000000003274, + 35.80000000000291, + 17.200000000002547, + 28.400000000003274, + 8.80000000000291, + 37.400000000003274, + 12.100000000002183 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "11": { - "shapes": [ + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame5 n2" + } + ], "elements": [], - "frame": 0, + "frame": 20, "group": 0, - "id": 33, - "label_id": 7, + "id": 181, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 100.14453125, - 246.03515625, - 408.8692551505537, - 327.5483359746413, - 588.5839936608554, - 289.0380348652925, - 623.8851030110927, - 183.77654516640177, - 329.2812995245622, - 71.45483359746322 + 17.600000000002183, + 14.900000000003274, + 27.200000000002547, + 21.600000000004002 ], "rotation": 0.0, "source": "manual", - "type": "polyline", + "type": "rectangle", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "13": { - "shapes": [ + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame6 n1" + } + ], "elements": [], - "frame": 0, + "frame": 21, "group": 0, - "id": 34, - "label_id": 16, + "id": 182, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 106.361328125, - 85.150390625, - 240.083984375, - 241.263671875 + 43.15465253950242, + 24.59525439814206, + 55.395253809205315, + 35.071444674014856 ], - "rotation": 45.9, + "rotation": 0.0, "source": "manual", "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame7 n1" + } + ], "elements": [], - "frame": 1, + "frame": 22, "group": 0, - "id": 35, - "label_id": 16, + "id": 183, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 414.29522752496996, - 124.8035516093205, - 522.2641509433943, - 286.75693673695605 + 38.50000000000182, + 9.600000000002183, + 51.80000000000109, + 17.100000000002183 ], "rotation": 0.0, "source": "manual", "type": "rectangle", "z_order": 0 - } - ], - "tags": [ - { - "attributes": [], - "frame": 2, - "group": 0, - "id": 1, - "label_id": 17, - "source": "manual" }, { - "attributes": [], - "frame": 3, + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame7 n2" + } + ], + "elements": [], + "frame": 22, "group": 0, - "id": 2, - "label_id": 16, - "source": "manual" + "id": 184, + "label_id": 77, + "occluded": false, + "outside": false, + "points": [ + 52.10000000000218, + 17.30000000000291, + 59.400000000001455, + 21.500000000003638 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 } ], + "tags": [], "tracks": [], "version": 0 }, - "14": { + "41": { "shapes": [ { "attributes": [ { - "spec_id": 2, - "value": "white" + "spec_id": 15, + "value": "gt frame1 n1" } ], - "elements": [ - { - "attributes": [ - { - "spec_id": 3, - "value": "val1" - } - ], - "frame": 0, - "group": 0, - "id": 39, - "label_id": 25, - "occluded": false, - "outside": false, - "points": [ - 259.91862203681984, - 67.8260869565238 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, + "elements": [], + "frame": 23, + "group": 0, + "id": 185, + "label_id": 77, + "occluded": false, + "outside": false, + "points": [ + 17.650000000003274, + 11.30000000000291, + 30.55000000000291, + 21.700000000002547 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 40, - "label_id": 26, - "occluded": false, - "outside": false, - "points": [ - 283.65217391304554, - 276.52173913043686 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, + "spec_id": 15, + "value": "gt frame2 n2" + } + ], + "elements": [], + "frame": 24, + "group": 0, + "id": 186, + "label_id": 77, + "occluded": false, + "outside": false, + "points": [ + 18.850000000002183, + 12.000000000001819, + 25.850000000002183, + 19.50000000000182 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 37, - "label_id": 23, - "occluded": false, - "outside": false, - "points": [ - 135.8260869565238, - 118.10276296228554 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, + "spec_id": 15, + "value": "gt frame2 n1" + } + ], + "elements": [], + "frame": 24, + "group": 0, + "id": 187, + "label_id": 77, + "occluded": false, + "outside": false, + "points": [ + 26.150000000003274, + 25.00000000000182, + 34.150000000003274, + 34.50000000000182 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 38, - "label_id": 24, - "occluded": false, - "outside": false, - "points": [ - 172.10450871201368, - 274.6245183225243 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 + "spec_id": 15, + "value": "gt frame3 n1" } ], - "frame": 0, + "elements": [], + "frame": 25, "group": 0, - "id": 36, - "label_id": 22, + "id": 188, + "label_id": 77, "occluded": false, "outside": false, - "points": [], + "points": [ + 24.600000000002183, + 11.500000000001819, + 37.10000000000218, + 18.700000000002547 + ], "rotation": 0.0, - "source": "manual", - "type": "skeleton", + "source": "Ground truth", + "type": "rectangle", "z_order": 0 - } - ], - "tags": [], - "tracks": [ + }, { "attributes": [ { - "spec_id": 2, - "value": "white" + "spec_id": 15, + "value": "gt frame5 n1" } ], - "elements": [ + "elements": [], + "frame": 27, + "group": 0, + "id": 189, + "label_id": 77, + "occluded": false, + "outside": false, + "points": [ + 17.863216443472993, + 36.43614886308387, + 41.266725327279346, + 42.765472201610464 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 2, - "label_id": 23, - "shapes": [ - { - "attributes": [], - "frame": 0, - "id": 2, - "occluded": false, - "outside": false, - "points": [ - 381.9130434782637, - 355.0592829431864 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 3, - "id": 6, - "occluded": false, - "outside": false, - "points": [ - 137.0966796875, - 156.11214469590232 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } - ], - "source": "manual" - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 3, - "label_id": 24, - "shapes": [ - { - "attributes": [], - "frame": 0, - "id": 3, - "occluded": false, - "outside": false, - "points": [ - 461.9389738212561, - 583.320176176868 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 3, - "id": 7, - "occluded": false, - "outside": false, - "points": [ - 217.12261003049207, - 384.3730379295848 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } - ], - "source": "manual" - }, - { - "attributes": [ - { - "spec_id": 3, - "value": "val1" - } - ], - "frame": 0, - "group": 0, - "id": 4, - "label_id": 25, - "shapes": [ - { - "attributes": [], - "frame": 0, - "id": 4, - "occluded": false, - "outside": false, - "points": [ - 655.6465767436227, - 281.7391304347839 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 3, - "id": 8, - "occluded": false, - "outside": false, - "points": [ - 410.83021295285835, - 82.7919921875 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } - ], - "source": "manual" - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 5, - "label_id": 26, - "shapes": [ - { - "attributes": [], - "frame": 0, - "id": 5, - "occluded": false, - "outside": false, - "points": [ - 708.000000000003, - 586.0869565217404 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 3, - "id": 9, - "occluded": false, - "outside": false, - "points": [ - 463.1836362092399, - 387.13981827445605 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } - ], - "source": "manual" + "spec_id": 15, + "value": "gt frame5 n2" } ], - "frame": 0, + "elements": [], + "frame": 27, "group": 0, - "id": 1, - "label_id": 22, - "shapes": [ - { - "attributes": [], - "frame": 0, - "id": 1, - "occluded": false, - "outside": false, - "points": [], - "rotation": 0.0, - "type": "skeleton", - "z_order": 0 - } + "id": 190, + "label_id": 77, + "occluded": false, + "outside": false, + "points": [ + 34.349609375, + 52.806640625, + 27.086274131672326, + 63.1830161588623, + 40.229131337355284, + 67.44868033965395, + 48.87574792004307, + 59.03264019917333, + 45.53238950807099, + 53.3835173651496 ], - "source": "manual" + "rotation": 0.0, + "source": "Ground truth", + "type": "polygon", + "z_order": 0 } ], + "tags": [], + "tracks": [], "version": 0 - }, - "15": { + } + }, + "task": { + "2": { "shapes": [ { "attributes": [], "elements": [], "frame": 0, "group": 0, - "id": 44, - "label_id": 29, + "id": 1, + "label_id": 3, "occluded": false, "outside": false, "points": [ - 479.97322623828586, - 408.0053547523421, - 942.6238286479238, - 513.3868808567604 + 223.39453125, + 226.0751953125, + 513.7663269042969, + 377.9619903564453 ], "rotation": 0.0, "source": "manual", @@ -5367,176 +5273,144 @@ { "attributes": [], "elements": [], - "frame": 0, + "frame": 1, "group": 0, - "id": 43, - "label_id": 30, + "id": 2, + "label_id": 3, "occluded": false, "outside": false, "points": [ - 120.81927710843593, - 213.52074966532928, - 258.7576974564945, - 643.614457831327 + 63.0791015625, + 139.75390625, + 132.19337349397574, + 112.3867469879533, + 189.71144578313397, + 159.23614457831354, + 191.1030120481937, + 246.9048192771097, + 86.73554216867524, + 335.5012048192784, + 32.00060240964012, + 250.15180722891637 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "17": { - "shapes": [ + }, { "attributes": [], "elements": [], - "frame": 0, + "frame": 1, "group": 0, - "id": 47, - "label_id": 38, + "id": 3, + "label_id": 4, "occluded": false, "outside": false, "points": [ - 106.361328125, - 85.150390625, - 240.083984375, - 241.263671875 + 83.0244140625, + 216.75390625, + 112.24759036144678, + 162.48313253012202, + 167.44638554216908, + 183.35662650602535, + 149.35602409638705, + 252.0072289156633, + 84.41626506024113, + 292.8265060240974, + 72.81987951807241, + 258.9650602409638 ], - "rotation": 45.9, + "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 }, { "attributes": [], "elements": [], - "frame": 1, + "frame": 2, "group": 0, - "id": 48, - "label_id": 38, + "id": 4, + "label_id": 3, "occluded": false, "outside": false, "points": [ - 414.29522752496996, - 124.8035516093205, - 522.2641509433943, - 286.75693673695605 + 24.443359375, + 107.2275390625, + 84.91109877913368, + 61.125083240844106, + 169.4316315205324, + 75.1561598224198, + 226.5581576026634, + 113.90865704772477, + 240.5892341842391, + 205.77880133185317, + 210.52264150943483, + 270.9230854605994 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polyline", "z_order": 0 - } - ], - "tags": [ - { - "attributes": [], - "frame": 2, - "group": 0, - "id": 5, - "label_id": 39, - "source": "manual" }, { "attributes": [], - "frame": 3, + "elements": [], + "frame": 22, "group": 0, - "id": 6, - "label_id": 38, - "source": "manual" + "id": 5, + "label_id": 3, + "occluded": false, + "outside": false, + "points": [ + 148.94921875, + 285.6865234375, + 313.515094339622, + 400.32830188679145, + 217.36415094339463, + 585.2339622641503, + 64.81698113207494, + 499.25283018867776 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 } ], + "tags": [], "tracks": [], "version": 0 }, - "18": { + "5": { "shapes": [ { "attributes": [], - "elements": [ - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 52, - "label_id": 49, - "occluded": false, - "outside": false, - "points": [ - 326.2062528608664, - 107.42983682983868 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 50, - "label_id": 47, - "occluded": false, - "outside": false, - "points": [ - 136.46993006993034, - 138.72697241590762 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 51, - "label_id": 48, - "occluded": false, - "outside": false, - "points": [ - 192.9001336620433, - 421.9659673659692 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 53, - "label_id": 50, - "occluded": false, - "outside": false, - "points": [ - 412.07832167832197, - 337.46374412038085 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 0 - } - ], + "elements": [], "frame": 0, "group": 0, - "id": 49, - "label_id": 46, + "id": 29, + "label_id": 9, "occluded": false, "outside": false, - "points": [], + "points": [ + 364.0361328125, + 528.87890625, + 609.5286041189956, + 586.544622425632, + 835.2494279176244, + 360.0000000000018, + 543.6247139588122, + 175.4691075514893, + 326.9656750572103, + 192.76887871853796, + 244.58581235698148, + 319.63386727689067 + ], "rotation": 0.0, "source": "manual", - "type": "skeleton", + "type": "polygon", "z_order": 0 } ], @@ -5544,56 +5418,69 @@ "tracks": [], "version": 0 }, - "19": { + "6": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "7": { "shapes": [ { - "attributes": [ - { - "spec_id": 7, - "value": "non-default" - } - ], + "attributes": [], "elements": [], "frame": 0, "group": 0, - "id": 54, - "label_id": 51, + "id": 27, + "label_id": 11, "occluded": false, "outside": false, "points": [ - 244.32906271072352, - 57.53054619015711, - 340.34389750505943, - 191.28914362778414 + 448.3779296875, + 356.4892578125, + 438.2558352402775, + 761.3861556064112, + 744.1780320366161, + 319.37356979405195, + 446.1288329519466, + 163.03832951945333 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 - }, + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "8": { + "shapes": [ { - "attributes": [ - { - "spec_id": 8, - "value": "black" - } - ], + "attributes": [], "elements": [], "frame": 0, "group": 0, - "id": 55, - "label_id": 52, + "id": 30, + "label_id": 13, "occluded": false, "outside": false, "points": [ - 424.4396493594086, - 86.6660822656795, - 664.8078219824692, - 251.54672960215976 + 440.0439453125, + 84.0791015625, + 71.83311938382576, + 249.81514762516053, + 380.4441591784325, + 526.585365853658, + 677.6251604621302, + 260.42875481386363, + 629.4557124518615, + 127.35044929396645 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 } ], @@ -5601,27 +5488,22 @@ "tracks": [], "version": 0 }, - "20": { + "9": { "shapes": [ { - "attributes": [ - { - "spec_id": 9, - "value": "non-default" - } - ], + "attributes": [], "elements": [], "frame": 0, "group": 0, - "id": 56, - "label_id": 53, + "id": 31, + "label_id": 6, "occluded": false, "outside": false, "points": [ - 35.913636363637124, - 80.58636363636288, - 94.8227272727272, - 170.58636363636288 + 65.6189987163034, + 100.96585365853753, + 142.12734274711147, + 362.6243902439037 ], "rotation": 0.0, "source": "manual", @@ -5629,24 +5511,44 @@ "z_order": 0 }, { - "attributes": [ - { - "spec_id": 10, - "value": "black" - } - ], + "attributes": [], "elements": [], - "frame": 0, + "frame": 15, "group": 0, - "id": 57, - "label_id": 54, + "id": 41, + "label_id": 6, "occluded": false, "outside": false, "points": [ - 190.95909090909117, - 100.22272727272684, - 297.7318181818191, - 209.8590909090908 + 53.062929061787145, + 301.6390160183091, + 197.94851258581548, + 763.3266590389048 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 1, + "value": "mazda" + } + ], + "elements": [], + "frame": 16, + "group": 0, + "id": 42, + "label_id": 5, + "occluded": false, + "outside": false, + "points": [ + 172.0810546875, + 105.990234375, + 285.97262095255974, + 138.40000000000146 ], "rotation": 0.0, "source": "manual", @@ -5658,22 +5560,129 @@ "tracks": [], "version": 0 }, - "21": { + "11": { + "shapes": [ + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 33, + "label_id": 7, + "occluded": false, + "outside": false, + "points": [ + 100.14453125, + 246.03515625, + 408.8692551505537, + 327.5483359746413, + 588.5839936608554, + 289.0380348652925, + 623.8851030110927, + 183.77654516640177, + 329.2812995245622, + 71.45483359746322 + ], + "rotation": 0.0, + "source": "manual", + "type": "polyline", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "13": { "shapes": [ { "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 34, + "label_id": 16, + "occluded": false, + "outside": false, + "points": [ + 106.361328125, + 85.150390625, + 240.083984375, + 241.263671875 + ], + "rotation": 45.9, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [], + "elements": [], + "frame": 1, + "group": 0, + "id": 35, + "label_id": 16, + "occluded": false, + "outside": false, + "points": [ + 414.29522752496996, + 124.8035516093205, + 522.2641509433943, + 286.75693673695605 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [ + { + "attributes": [], + "frame": 2, + "group": 0, + "id": 1, + "label_id": 17, + "source": "manual" + }, + { + "attributes": [], + "frame": 3, + "group": 0, + "id": 2, + "label_id": 16, + "source": "manual" + } + ], + "tracks": [], + "version": 0 + }, + "14": { + "shapes": [ + { + "attributes": [ + { + "spec_id": 2, + "value": "white" + } + ], "elements": [ { - "attributes": [], - "frame": 6, + "attributes": [ + { + "spec_id": 3, + "value": "val1" + } + ], + "frame": 0, "group": 0, - "id": 62, - "label_id": 61, + "id": 39, + "label_id": 25, "occluded": false, "outside": false, "points": [ - 155.7276059652392, - 30.260833689097126 + 259.91862203681984, + 67.8260869565238 ], "rotation": 0.0, "source": "manual", @@ -5682,15 +5691,15 @@ }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 60, - "label_id": 59, + "id": 40, + "label_id": 26, "occluded": false, "outside": false, "points": [ - 103.73647295885894, - 51.085564225393554 + 283.65217391304554, + 276.52173913043686 ], "rotation": 0.0, "source": "manual", @@ -5699,15 +5708,15 @@ }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 61, - "label_id": 60, + "id": 37, + "label_id": 23, "occluded": false, "outside": false, "points": [ - 125.86783527366472, - 101.86367376801435 + 135.8260869565238, + 118.10276296228554 ], "rotation": 0.0, "source": "manual", @@ -5716,15 +5725,15 @@ }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 63, - "label_id": 62, + "id": 38, + "label_id": 24, "occluded": false, "outside": false, "points": [ - 199.28775272671066, - 114.13029555429613 + 172.10450871201368, + 274.6245183225243 ], "rotation": 0.0, "source": "manual", @@ -5732,10 +5741,10 @@ "z_order": 0 } ], - "frame": 6, + "frame": 0, "group": 0, - "id": 59, - "label_id": 58, + "id": 36, + "label_id": 22, "occluded": false, "outside": false, "points": [], @@ -5743,49 +5752,34 @@ "source": "manual", "type": "skeleton", "z_order": 0 - }, - { - "attributes": [], - "elements": [], - "frame": 6, - "group": 0, - "id": 58, - "label_id": 57, - "occluded": false, - "outside": false, - "points": [ - 42.63157931421483, - 51.228199155397306, - 106.13274329786509, - 138.0929989443539 - ], - "rotation": 0.0, - "source": "manual", - "type": "rectangle", - "z_order": 0 } ], "tags": [], "tracks": [ { - "attributes": [], + "attributes": [ + { + "spec_id": 2, + "value": "white" + } + ], "elements": [ { "attributes": [], "frame": 0, "group": 0, - "id": 7, - "label_id": 59, + "id": 2, + "label_id": 23, "shapes": [ { "attributes": [], "frame": 0, - "id": 11, + "id": 2, "occluded": false, - "outside": true, + "outside": false, "points": [ - 230.39103314621025, - 149.98846070356873 + 381.9130434782637, + 355.0592829431864 ], "rotation": 0.0, "type": "points", @@ -5794,26 +5788,12 @@ { "attributes": [], "frame": 3, - "id": 12, + "id": 6, "occluded": false, "outside": false, "points": [ - 230.39103314621025, - 149.98846070356873 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - }, - { - "attributes": [], - "frame": 6, - "id": 12, - "occluded": false, - "outside": true, - "points": [ - 230.39103314621025, - 149.98846070356873 + 137.0966796875, + 156.11214469590232 ], "rotation": 0.0, "type": "points", @@ -5826,18 +5806,18 @@ "attributes": [], "frame": 0, "group": 0, - "id": 8, - "label_id": 60, + "id": 3, + "label_id": 24, "shapes": [ { "attributes": [], "frame": 0, - "id": 13, + "id": 3, "occluded": false, "outside": false, "points": [ - 292.80597636674844, - 284.1818841927473 + 461.9389738212561, + 583.320176176868 ], "rotation": 0.0, "type": "points", @@ -5845,13 +5825,13 @@ }, { "attributes": [], - "frame": 6, - "id": 13, + "frame": 3, + "id": 7, "occluded": false, - "outside": true, + "outside": false, "points": [ - 292.80597636674844, - 284.1818841927473 + 217.12261003049207, + 384.3730379295848 ], "rotation": 0.0, "type": "points", @@ -5861,21 +5841,26 @@ "source": "manual" }, { - "attributes": [], + "attributes": [ + { + "spec_id": 3, + "value": "val1" + } + ], "frame": 0, "group": 0, - "id": 9, - "label_id": 61, + "id": 4, + "label_id": 25, "shapes": [ { "attributes": [], "frame": 0, - "id": 14, + "id": 4, "occluded": false, "outside": false, "points": [ - 377.016603158851, - 94.95407858346152 + 655.6465767436227, + 281.7391304347839 ], "rotation": 0.0, "type": "points", @@ -5883,13 +5868,13 @@ }, { "attributes": [], - "frame": 6, - "id": 14, + "frame": 3, + "id": 8, "occluded": false, - "outside": true, + "outside": false, "points": [ - 377.016603158851, - 94.95407858346152 + 410.83021295285835, + 82.7919921875 ], "rotation": 0.0, "type": "points", @@ -5902,18 +5887,18 @@ "attributes": [], "frame": 0, "group": 0, - "id": 10, - "label_id": 62, + "id": 5, + "label_id": 26, "shapes": [ { "attributes": [], "frame": 0, - "id": 15, + "id": 5, "occluded": false, "outside": false, "points": [ - 499.86507710826913, - 316.59939612801213 + 708.000000000003, + 586.0869565217404 ], "rotation": 0.0, "type": "points", @@ -5921,13 +5906,13 @@ }, { "attributes": [], - "frame": 6, - "id": 15, + "frame": 3, + "id": 9, "occluded": false, - "outside": true, + "outside": false, "points": [ - 499.86507710826913, - 316.59939612801213 + 463.1836362092399, + 387.13981827445605 ], "rotation": 0.0, "type": "points", @@ -5939,158 +5924,1380 @@ ], "frame": 0, "group": 0, - "id": 6, - "label_id": 58, + "id": 1, + "label_id": 22, "shapes": [ { "attributes": [], "frame": 0, - "id": 10, + "id": 1, "occluded": false, "outside": false, "points": [], "rotation": 0.0, "type": "skeleton", "z_order": 0 - }, - { - "attributes": [], - "frame": 6, - "id": 10, - "occluded": false, - "outside": true, - "points": [], - "rotation": 0.0, - "type": "skeleton", - "z_order": 0 } ], "source": "manual" + } + ], + "version": 0 + }, + "15": { + "shapes": [ + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 44, + "label_id": 29, + "occluded": false, + "outside": false, + "points": [ + 479.97322623828586, + 408.0053547523421, + 942.6238286479238, + 513.3868808567604 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 43, + "label_id": 30, + "occluded": false, + "outside": false, + "points": [ + 120.81927710843593, + 213.52074966532928, + 258.7576974564945, + 643.614457831327 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "17": { + "shapes": [ + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 47, + "label_id": 38, + "occluded": false, + "outside": false, + "points": [ + 106.361328125, + 85.150390625, + 240.083984375, + 241.263671875 + ], + "rotation": 45.9, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [], + "elements": [], + "frame": 1, + "group": 0, + "id": 48, + "label_id": 38, + "occluded": false, + "outside": false, + "points": [ + 414.29522752496996, + 124.8035516093205, + 522.2641509433943, + 286.75693673695605 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [ + { + "attributes": [], + "frame": 2, + "group": 0, + "id": 5, + "label_id": 39, + "source": "manual" }, + { + "attributes": [], + "frame": 3, + "group": 0, + "id": 6, + "label_id": 38, + "source": "manual" + } + ], + "tracks": [], + "version": 0 + }, + "18": { + "shapes": [ { "attributes": [], "elements": [ { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 12, - "label_id": 59, - "shapes": [ - { - "attributes": [], - "frame": 6, - "id": 17, - "occluded": false, - "outside": false, - "points": [ - 92.95325643333308, - 129.2954675940839 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } + "id": 52, + "label_id": 49, + "occluded": false, + "outside": false, + "points": [ + 326.2062528608664, + 107.42983682983868 ], - "source": "manual" + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 13, - "label_id": 60, - "shapes": [ - { - "attributes": [], - "frame": 6, - "id": 18, - "occluded": false, - "outside": false, - "points": [ - 133.81649280769233, - 195.4883603907146 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } + "id": 50, + "label_id": 47, + "occluded": false, + "outside": false, + "points": [ + 136.46993006993034, + 138.72697241590762 ], - "source": "manual" + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 14, - "label_id": 61, - "shapes": [ - { - "attributes": [], - "frame": 6, - "id": 19, - "occluded": false, - "outside": false, - "points": [ - 188.94942364574058, - 102.14894385926891 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } + "id": 51, + "label_id": 48, + "occluded": false, + "outside": false, + "points": [ + 192.9001336620433, + 421.9659673659692 ], - "source": "manual" + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 }, { "attributes": [], - "frame": 6, + "frame": 0, "group": 0, - "id": 15, - "label_id": 62, - "shapes": [ - { - "attributes": [], - "frame": 6, - "id": 20, - "occluded": false, - "outside": false, - "points": [ - 269.3786601426267, - 211.47877807640333 - ], - "rotation": 0.0, - "type": "points", - "z_order": 0 - } + "id": 53, + "label_id": 50, + "occluded": false, + "outside": false, + "points": [ + 412.07832167832197, + 337.46374412038085 ], - "source": "manual" + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 } ], - "frame": 6, + "frame": 0, "group": 0, - "id": 11, - "label_id": 58, - "shapes": [ + "id": 49, + "label_id": 46, + "occluded": false, + "outside": false, + "points": [], + "rotation": 0.0, + "source": "manual", + "type": "skeleton", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "19": { + "shapes": [ + { + "attributes": [ + { + "spec_id": 7, + "value": "non-default" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 54, + "label_id": 51, + "occluded": false, + "outside": false, + "points": [ + 244.32906271072352, + 57.53054619015711, + 340.34389750505943, + 191.28914362778414 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 8, + "value": "black" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 55, + "label_id": 52, + "occluded": false, + "outside": false, + "points": [ + 424.4396493594086, + 86.6660822656795, + 664.8078219824692, + 251.54672960215976 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "20": { + "shapes": [ + { + "attributes": [ + { + "spec_id": 9, + "value": "non-default" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 56, + "label_id": 53, + "occluded": false, + "outside": false, + "points": [ + 35.913636363637124, + 80.58636363636288, + 94.8227272727272, + 170.58636363636288 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 10, + "value": "black" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 57, + "label_id": 54, + "occluded": false, + "outside": false, + "points": [ + 190.95909090909117, + 100.22272727272684, + 297.7318181818191, + 209.8590909090908 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "21": { + "shapes": [ + { + "attributes": [], + "elements": [ { "attributes": [], "frame": 6, - "id": 16, + "group": 0, + "id": 62, + "label_id": 61, "occluded": false, "outside": false, - "points": [], + "points": [ + 155.7276059652392, + 30.260833689097126 + ], "rotation": 0.0, - "type": "skeleton", + "source": "manual", + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 60, + "label_id": 59, + "occluded": false, + "outside": false, + "points": [ + 103.73647295885894, + 51.085564225393554 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 61, + "label_id": 60, + "occluded": false, + "outside": false, + "points": [ + 125.86783527366472, + 101.86367376801435 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 63, + "label_id": 62, + "occluded": false, + "outside": false, + "points": [ + 199.28775272671066, + 114.13029555429613 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", "z_order": 0 } ], - "source": "manual" - } - ], - "version": 0 - }, - "22": { - "shapes": [ + "frame": 6, + "group": 0, + "id": 59, + "label_id": 58, + "occluded": false, + "outside": false, + "points": [], + "rotation": 0.0, + "source": "manual", + "type": "skeleton", + "z_order": 0 + }, + { + "attributes": [], + "elements": [], + "frame": 6, + "group": 0, + "id": 58, + "label_id": 57, + "occluded": false, + "outside": false, + "points": [ + 42.63157931421483, + 51.228199155397306, + 106.13274329786509, + 138.0929989443539 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [ + { + "attributes": [], + "elements": [ + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 7, + "label_id": 59, + "shapes": [ + { + "attributes": [], + "frame": 0, + "id": 11, + "occluded": false, + "outside": true, + "points": [ + 230.39103314621025, + 149.98846070356873 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 3, + "id": 12, + "occluded": false, + "outside": false, + "points": [ + 230.39103314621025, + 149.98846070356873 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "id": 12, + "occluded": false, + "outside": true, + "points": [ + 230.39103314621025, + 149.98846070356873 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 8, + "label_id": 60, + "shapes": [ + { + "attributes": [], + "frame": 0, + "id": 13, + "occluded": false, + "outside": false, + "points": [ + 292.80597636674844, + 284.1818841927473 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "id": 13, + "occluded": false, + "outside": true, + "points": [ + 292.80597636674844, + 284.1818841927473 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 9, + "label_id": 61, + "shapes": [ + { + "attributes": [], + "frame": 0, + "id": 14, + "occluded": false, + "outside": false, + "points": [ + 377.016603158851, + 94.95407858346152 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "id": 14, + "occluded": false, + "outside": true, + "points": [ + 377.016603158851, + 94.95407858346152 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 10, + "label_id": 62, + "shapes": [ + { + "attributes": [], + "frame": 0, + "id": 15, + "occluded": false, + "outside": false, + "points": [ + 499.86507710826913, + 316.59939612801213 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "id": 15, + "occluded": false, + "outside": true, + "points": [ + 499.86507710826913, + 316.59939612801213 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + } + ], + "frame": 0, + "group": 0, + "id": 6, + "label_id": 58, + "shapes": [ + { + "attributes": [], + "frame": 0, + "id": 10, + "occluded": false, + "outside": false, + "points": [], + "rotation": 0.0, + "type": "skeleton", + "z_order": 0 + }, + { + "attributes": [], + "frame": 6, + "id": 10, + "occluded": false, + "outside": true, + "points": [], + "rotation": 0.0, + "type": "skeleton", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "elements": [ + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 12, + "label_id": 59, + "shapes": [ + { + "attributes": [], + "frame": 6, + "id": 17, + "occluded": false, + "outside": false, + "points": [ + 92.95325643333308, + 129.2954675940839 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 13, + "label_id": 60, + "shapes": [ + { + "attributes": [], + "frame": 6, + "id": 18, + "occluded": false, + "outside": false, + "points": [ + 133.81649280769233, + 195.4883603907146 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 14, + "label_id": 61, + "shapes": [ + { + "attributes": [], + "frame": 6, + "id": 19, + "occluded": false, + "outside": false, + "points": [ + 188.94942364574058, + 102.14894385926891 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + }, + { + "attributes": [], + "frame": 6, + "group": 0, + "id": 15, + "label_id": 62, + "shapes": [ + { + "attributes": [], + "frame": 6, + "id": 20, + "occluded": false, + "outside": false, + "points": [ + 269.3786601426267, + 211.47877807640333 + ], + "rotation": 0.0, + "type": "points", + "z_order": 0 + } + ], + "source": "manual" + } + ], + "frame": 6, + "group": 0, + "id": 11, + "label_id": 58, + "shapes": [ + { + "attributes": [], + "frame": 6, + "id": 16, + "occluded": false, + "outside": false, + "points": [], + "rotation": 0.0, + "type": "skeleton", + "z_order": 0 + } + ], + "source": "manual" + } + ], + "version": 0 + }, + "22": { + "shapes": [ + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 3, + "id": 64, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 439.001953125, + 238.072265625, + 442.27361979103625, + 279.29221193910234 + ], + "rotation": 0.0, + "source": "manual", + "type": "polyline", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yz" + }, + { + "spec_id": 14, + "value": "2" + } + ], + "elements": [], + "frame": 0, + "group": 1, + "id": 65, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 109.6181640625, + 93.6806640625, + 150.7451171875, + 154.708984375 + ], + "rotation": 118.39999999999998, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 3, + "id": 66, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 414.140625, + 256.392578125, + 467.1372624053729, + 255.08366924483562 + ], + "rotation": 0.0, + "source": "manual", + "type": "polyline", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 4, + "id": 67, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 210.00390625, + 274.0576171875, + 240.10078833736043, + 258.3547764811883, + 267.10000000000036, + 266.40000000000146, + 278.7035955631618, + 261.62685883520135, + 281.32071996591367, + 253.77548562694574 + ], + "rotation": 0.0, + "source": "manual", + "type": "polyline", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 5, + "id": 68, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 227.015625, + 87.587890625, + 225.052845018663, + 153.01643273565423, + 283.90000000000146, + 158.20000000000073, + 251.90000000000146, + 121.0 + ], + "rotation": 0.0, + "source": "manual", + "type": "polygon", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 2, + "id": 69, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 37.0, + 25.0, + 49.0, + 32.0, + 23.0, + 59.0, + 19.0, + 61.0, + 17.0, + 63.0, + 15.0, + 65.0, + 14.0, + 66.0, + 12.0, + 68.0, + 11.0, + 69.0, + 10.0, + 70.0, + 9.0, + 70.0, + 9.0, + 70.0, + 9.0, + 70.0, + 9.0, + 71.0, + 8.0, + 71.0, + 8.0, + 72.0, + 7.0, + 72.0, + 7.0, + 72.0, + 7.0, + 72.0, + 7.0, + 72.0, + 7.0, + 73.0, + 6.0, + 73.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 5.0, + 74.0, + 4.0, + 75.0, + 4.0, + 75.0, + 4.0, + 75.0, + 3.0, + 76.0, + 3.0, + 76.0, + 3.0, + 77.0, + 2.0, + 77.0, + 2.0, + 77.0, + 2.0, + 77.0, + 2.0, + 77.0, + 2.0, + 77.0, + 1.0, + 1184.0, + 1.0, + 77.0, + 3.0, + 76.0, + 4.0, + 75.0, + 4.0, + 75.0, + 4.0, + 74.0, + 5.0, + 73.0, + 7.0, + 71.0, + 9.0, + 67.0, + 13.0, + 65.0, + 15.0, + 9.0, + 1.0, + 53.0, + 29.0, + 16.0, + 4.0, + 29.0, + 31.0, + 11.0, + 16.0, + 20.0, + 12.0, + 458.0, + 87.0, + 536.0, + 158.0 + ], + "rotation": 0.0, + "source": "manual", + "type": "mask", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 6, + "id": 71, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 414.48681640625, + 261.001953125, + 467.4834538116229, + 259.6930442448356 + ], + "rotation": 0.0, + "source": "manual", + "type": "polyline", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 72, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 502.5, + 319.90000000000146, + 635.3000000000011, + 319.90000000000146, + 651.0, + 374.7000000000007, + 499.90000000000146, + 375.5 + ], + "rotation": 0.0, + "source": "manual", + "type": "polygon", + "z_order": 3 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 75, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 673.2000000000007, + 222.40000000000146, + 693.5177345278626, + 240.03542476937582, + 647.8000000000011, + 287.2000000000007, + 620.8925323514832, + 266.8609498975875 + ], + "rotation": 0.0, + "source": "manual", + "type": "polygon", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 0, + "id": 76, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 310.763369496879, + 196.19874876639187, + 339.55173792715505, + 228.9128038007966 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 4 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 15, + "id": 77, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 213.966796875, + 46.7294921875, + 239.5, + 72.30000000000109 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 16, + "id": 78, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 147.900390625, + 45.4208984375, + 171.40000000000146, + 70.30000000000109 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 16, + "id": 79, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 179.9443359375, + 46.0751953125, + 206.0, + 72.80000000000109 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 16, + "id": 80, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 113.80000000000109, + 45.400000000001455, + 137.3818359375, + 69.0 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 12, + "id": 81, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 147.8466796875, + 17.6083984375, + 170.1457031250011, + 39.871289443968635 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, + { + "attributes": [ + { + "spec_id": 13, + "value": "yy" + }, + { + "spec_id": 14, + "value": "1" + } + ], + "elements": [], + "frame": 0, + "group": 7, + "id": 82, + "label_id": 67, + "occluded": false, + "outside": false, + "points": [ + 113.80000000000109, + 17.600000000000364, + 138.65410232543945, + 40.47109413146973 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 1 + }, { "attributes": [ { @@ -6104,50 +7311,50 @@ ], "elements": [], "frame": 0, - "group": 3, - "id": 64, + "group": 7, + "id": 83, "label_id": 67, "occluded": false, "outside": false, "points": [ - 439.001953125, - 238.072265625, - 442.27361979103625, - 279.29221193910234 + 179.908203125, + 18.216796875, + 203.40000000000146, + 41.80000000000109 ], "rotation": 0.0, "source": "manual", - "type": "polyline", - "z_order": 0 + "type": "rectangle", + "z_order": 1 }, { "attributes": [ { "spec_id": 13, - "value": "yz" + "value": "yy" }, { "spec_id": 14, - "value": "2" + "value": "1" } ], "elements": [], "frame": 0, - "group": 1, - "id": 65, + "group": 11, + "id": 84, "label_id": 67, "occluded": false, "outside": false, "points": [ - 109.6181640625, - 93.6806640625, - 150.7451171875, - 154.708984375 + 603.9000000000015, + 13.654296875, + 632.6692943572998, + 42.400000000001455 ], - "rotation": 118.39999999999998, + "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 0 + "z_order": 1 }, { "attributes": [ @@ -6162,21 +7369,21 @@ ], "elements": [], "frame": 0, - "group": 3, - "id": 66, + "group": 11, + "id": 85, "label_id": 67, "occluded": false, "outside": false, "points": [ - 414.140625, - 256.392578125, - 467.1372624053729, - 255.08366924483562 + 641.2000000000007, + 13.0, + 670.6457023620605, + 42.5 ], "rotation": 0.0, "source": "manual", - "type": "polyline", - "z_order": 0 + "type": "rectangle", + "z_order": 1 }, { "attributes": [ @@ -6191,27 +7398,21 @@ ], "elements": [], "frame": 0, - "group": 4, - "id": 67, + "group": 11, + "id": 86, "label_id": 67, "occluded": false, "outside": false, "points": [ - 210.00390625, - 274.0576171875, - 240.10078833736043, - 258.3547764811883, - 267.10000000000036, - 266.40000000000146, - 278.7035955631618, - 261.62685883520135, - 281.32071996591367, - 253.77548562694574 + 681.0859375, + 13.0, + 711.3000000000011, + 43.20000000000073 ], "rotation": 0.0, "source": "manual", - "type": "polyline", - "z_order": 0 + "type": "rectangle", + "z_order": 1 }, { "attributes": [ @@ -6226,25 +7427,21 @@ ], "elements": [], "frame": 0, - "group": 5, - "id": 68, + "group": 12, + "id": 87, "label_id": 67, "occluded": false, "outside": false, "points": [ - 227.015625, - 87.587890625, - 225.052845018663, - 153.01643273565423, - 283.90000000000146, - 158.20000000000073, - 251.90000000000146, - 121.0 + 212.6220703125, + 18.9169921875, + 236.8000000000011, + 42.5 ], "rotation": 0.0, "source": "manual", - "type": "polygon", - "z_order": 0 + "type": "rectangle", + "z_order": 1 }, { "attributes": [ @@ -6259,144 +7456,23 @@ ], "elements": [], "frame": 0, - "group": 2, - "id": 69, + "group": 0, + "id": 88, "label_id": 67, "occluded": false, "outside": false, "points": [ - 37.0, - 25.0, - 49.0, - 32.0, - 23.0, - 59.0, - 19.0, - 61.0, - 17.0, - 63.0, - 15.0, - 65.0, - 14.0, - 66.0, - 12.0, - 68.0, - 11.0, - 69.0, - 10.0, - 70.0, - 9.0, - 70.0, - 9.0, - 70.0, - 9.0, - 70.0, - 9.0, - 71.0, - 8.0, - 71.0, - 8.0, - 72.0, - 7.0, - 72.0, - 7.0, - 72.0, - 7.0, - 72.0, - 7.0, - 72.0, - 7.0, - 73.0, - 6.0, - 73.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 5.0, - 74.0, - 4.0, - 75.0, - 4.0, - 75.0, - 4.0, - 75.0, - 3.0, - 76.0, - 3.0, - 76.0, - 3.0, - 77.0, - 2.0, - 77.0, - 2.0, - 77.0, - 2.0, - 77.0, - 2.0, - 77.0, - 2.0, - 77.0, - 1.0, - 1184.0, - 1.0, - 77.0, - 3.0, - 76.0, - 4.0, - 75.0, - 4.0, - 75.0, - 4.0, - 74.0, - 5.0, - 73.0, - 7.0, - 71.0, - 9.0, - 67.0, - 13.0, - 65.0, - 15.0, - 9.0, - 1.0, - 53.0, - 29.0, - 16.0, - 4.0, - 29.0, - 31.0, - 11.0, - 16.0, - 20.0, - 12.0, - 458.0, - 87.0, - 536.0, - 158.0 + 361.0, + 302.10000000000036, + 368.90000000000146, + 316.0, + 348.10000000000036, + 318.60000000000036 ], "rotation": 0.0, "source": "manual", - "type": "mask", - "z_order": 0 + "type": "points", + "z_order": 4 }, { "attributes": [ @@ -6411,21 +7487,19 @@ ], "elements": [], "frame": 0, - "group": 6, - "id": 71, + "group": 17, + "id": 90, "label_id": 67, "occluded": false, "outside": false, "points": [ - 414.48681640625, - 261.001953125, - 467.4834538116229, - 259.6930442448356 + 60.828125, + 302.1923828125 ], "rotation": 0.0, "source": "manual", - "type": "polyline", - "z_order": 0 + "type": "points", + "z_order": 4 }, { "attributes": [ @@ -6440,25 +7514,21 @@ ], "elements": [], "frame": 0, - "group": 0, - "id": 72, + "group": 17, + "id": 91, "label_id": 67, "occluded": false, "outside": false, "points": [ - 502.5, - 319.90000000000146, - 635.3000000000011, - 319.90000000000146, - 651.0, - 374.7000000000007, - 499.90000000000146, - 375.5 + 34.65674500649766, + 268.16966984208375, + 75.22217324915982, + 324.43784450126077 ], "rotation": 0.0, "source": "manual", - "type": "polygon", - "z_order": 3 + "type": "rectangle", + "z_order": 4 }, { "attributes": [ @@ -6474,24 +7544,20 @@ "elements": [], "frame": 0, "group": 0, - "id": 75, + "id": 93, "label_id": 67, "occluded": false, "outside": false, "points": [ - 673.2000000000007, - 222.40000000000146, - 693.5177345278626, - 240.03542476937582, - 647.8000000000011, - 287.2000000000007, - 620.8925323514832, - 266.8609498975875 + 138.0, + 339.5, + 155.0, + 338.8000000000011 ], "rotation": 0.0, "source": "manual", - "type": "polygon", - "z_order": 1 + "type": "points", + "z_order": 4 }, { "attributes": [ @@ -6507,19 +7573,23 @@ "elements": [], "frame": 0, "group": 0, - "id": 76, + "id": 94, "label_id": 67, "occluded": false, "outside": false, "points": [ - 310.763369496879, - 196.19874876639187, - 339.55173792715505, - 228.9128038007966 + 43.162109375, + 178.533203125, + 59.51942683264497, + 190.96449996088631, + 71.29648664503111, + 177.22459684643582, + 100.08485507530895, + 167.41038033611403 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polyline", "z_order": 4 }, { @@ -6535,21 +7605,27 @@ ], "elements": [], "frame": 0, - "group": 15, - "id": 77, + "group": 0, + "id": 95, "label_id": 67, "occluded": false, "outside": false, "points": [ - 213.966796875, - 46.7294921875, - 239.5, - 72.30000000000109 + 101.3935546875, + 200.7783203125, + 135.41603451246556, + 214.5186195856586, + 117.75044479388816, + 248.54123682144018, + 85.03638975948161, + 235.4556148076772, + 73.25932994709547, + 204.0501219746493 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", - "z_order": 1 + "type": "polygon", + "z_order": 4 }, { "attributes": [ @@ -6564,829 +7640,895 @@ ], "elements": [], "frame": 0, - "group": 16, - "id": 78, + "group": 0, + "id": 96, "label_id": 67, "occluded": false, "outside": false, "points": [ - 147.900390625, - 45.4208984375, - 171.40000000000146, - 70.30000000000109 + 216.5673828125, + 208.00537109375, + 252.552734375, + 191.6484375 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", - "z_order": 1 + "type": "ellipse", + "z_order": 4 + }, + { + "attributes": [], + "elements": [ + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 149, + "label_id": 71, + "occluded": true, + "outside": false, + "points": [ + 699.3046875, + 102.618369002279 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 148, + "label_id": 70, + "occluded": false, + "outside": false, + "points": [ + 738.9471427604549, + 102.15625 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 147, + "label_id": 69, + "occluded": false, + "outside": true, + "points": [ + 698.337805670104, + 67.95976734549367 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 150, + "label_id": 72, + "occluded": false, + "outside": true, + "points": [ + 700.7550293506938, + 134.04215851499248 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + } + ], + "frame": 0, + "group": 0, + "id": 142, + "label_id": 68, + "occluded": false, + "outside": false, + "points": [], + "rotation": 0.0, + "source": "manual", + "type": "skeleton", + "z_order": 4 + }, + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 89, + "label_id": 66, + "occluded": false, + "outside": false, + "points": [ + 225.70703125, + 314.6240234375 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 73, + "label_id": 66, + "occluded": false, + "outside": false, + "points": [ + 532.6000000000004, + 300.2000000000007, + 533.2000000000007, + 391.8000000000011, + 678.4693480835958, + 393.13736007351145, + 639.866763142998, + 300.8837248764885 + ], + "rotation": 0.0, + "source": "manual", + "type": "polygon", + "z_order": 4 + }, + { + "attributes": [], + "elements": [], + "frame": 0, + "group": 0, + "id": 74, + "label_id": 66, + "occluded": false, + "outside": false, + "points": [ + 618.228515625, + 215.12753906250146, + 688.3285156250004, + 284.5275390625011, + 690.2463290244214, + 278.63711201903425, + 626.1000000000004, + 206.70000000000073 + ], + "rotation": 0.0, + "source": "manual", + "type": "polygon", + "z_order": 3 }, { - "attributes": [ + "attributes": [], + "elements": [ { - "spec_id": 13, - "value": "yy" + "attributes": [], + "frame": 0, + "group": 0, + "id": 146, + "label_id": 72, + "occluded": false, + "outside": true, + "points": [ + 615.6438723666288, + 128.1533203125 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 }, { - "spec_id": 14, - "value": "1" + "attributes": [], + "frame": 0, + "group": 0, + "id": 143, + "label_id": 69, + "occluded": false, + "outside": false, + "points": [ + 616.3137942416288, + 65.9970703125 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 145, + "label_id": 71, + "occluded": false, + "outside": false, + "points": [ + 585.0322265625, + 97.32024233915763 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 + }, + { + "attributes": [], + "frame": 0, + "group": 0, + "id": 144, + "label_id": 70, + "occluded": false, + "outside": false, + "points": [ + 616.9673812833025, + 96.87642507954297 + ], + "rotation": 0.0, + "source": "manual", + "type": "points", + "z_order": 4 } ], - "elements": [], "frame": 0, - "group": 16, - "id": 79, - "label_id": 67, + "group": 0, + "id": 141, + "label_id": 68, "occluded": false, "outside": false, - "points": [ - 179.9443359375, - 46.0751953125, - 206.0, - 72.80000000000109 - ], + "points": [], "rotation": 0.0, "source": "manual", - "type": "rectangle", - "z_order": 1 + "type": "skeleton", + "z_order": 4 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], "frame": 0, - "group": 16, - "id": 80, - "label_id": 67, + "group": 17, + "id": 92, + "label_id": 66, "occluded": false, "outside": false, "points": [ - 113.80000000000109, - 45.400000000001455, - 137.3818359375, - 69.0 + 50.359375, + 283.8720703125 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", - "z_order": 1 + "type": "points", + "z_order": 4 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], "frame": 0, - "group": 12, - "id": 81, - "label_id": 67, + "group": 0, + "id": 70, + "label_id": 66, "occluded": false, "outside": false, "points": [ - 147.8466796875, - 17.6083984375, - 170.1457031250011, - 39.871289443968635 + 459.5, + 81.90000000000146, + 545.8000000000011, + 155.80020141601562 ], "rotation": 0.0, "source": "manual", "type": "rectangle", "z_order": 1 - }, + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "23": { + "shapes": [ { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], "frame": 0, - "group": 7, - "id": 82, - "label_id": 67, + "group": 0, + "id": 151, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 113.80000000000109, - 17.600000000000364, - 138.65410232543945, - 40.47109413146973 + 44.898003339767456, + 63.52153968811035, + 426.46817803382874, + 271.5955777168274 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 7, - "id": 83, - "label_id": 67, + "frame": 1, + "group": 0, + "id": 152, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 179.908203125, - 18.216796875, - 203.40000000000146, - 41.80000000000109 + 43.79357561469078, + 28.97564932703972, + 136.88880816102028, + 164.59188774228096 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 11, - "id": 84, - "label_id": 67, + "frame": 2, + "group": 0, + "id": 153, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 603.9000000000015, - 13.654296875, - 632.6692943572998, - 42.400000000001455 + 108.50460643768383, + 165.35334844589306, + 795.2833482742317, + 679.8631751060493 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 11, - "id": 85, - "label_id": 67, + "frame": 3, + "group": 0, + "id": 154, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 641.2000000000007, - 13.0, - 670.6457023620605, - 42.5 + 40.11789474487341, + 128.13260302543677, + 104.97083768844641, + 182.6914280414585 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 11, - "id": 86, - "label_id": 67, + "frame": 4, + "group": 0, + "id": 155, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 681.0859375, - 13.0, - 711.3000000000011, - 43.20000000000073 + 19.106867015361786, + 71.99510070085489, + 200.8463572859764, + 196.5581221222874 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 12, - "id": 87, - "label_id": 67, + "frame": 5, + "group": 0, + "id": 156, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 212.6220703125, - 18.9169921875, - 236.8000000000011, - 42.5 + 55.147965204716456, + 27.993821200729144, + 354.91261003613545, + 129.34078386128022 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 1 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, + "frame": 6, "group": 0, - "id": 88, - "label_id": 67, + "id": 157, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 361.0, - 302.10000000000036, - 368.90000000000146, - 316.0, - 348.10000000000036, - 318.60000000000036 + 55.67655109167208, + 27.202181529999507, + 314.5855129838001, + 333.9054008126259 ], "rotation": 0.0, "source": "manual", - "type": "points", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 17, - "id": 90, - "label_id": 67, + "frame": 7, + "group": 0, + "id": 158, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 60.828125, - 302.1923828125 + 30.75535711050179, + 51.1681019723419, + 245.49246947169377, + 374.42159963250197 ], "rotation": 0.0, "source": "manual", - "type": "points", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, - "group": 17, - "id": 91, - "label_id": 67, + "frame": 8, + "group": 0, + "id": 159, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 34.65674500649766, - 268.16966984208375, - 75.22217324915982, - 324.43784450126077 + 78.72917294502258, + 28.6186763048172, + 456.07723474502563, + 214.25403320789337 ], "rotation": 0.0, "source": "manual", "type": "rectangle", - "z_order": 4 + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, + "frame": 9, "group": 0, - "id": 93, - "label_id": 67, + "id": 160, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 138.0, - 339.5, - 155.0, - 338.8000000000011 + 40.30229317843805, + 20.90870136916601, + 277.9420801371325, + 141.0943407505747 ], "rotation": 0.0, "source": "manual", - "type": "points", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [ - { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" - } - ], + "attributes": [], "elements": [], - "frame": 0, + "frame": 10, "group": 0, - "id": 94, - "label_id": 67, + "id": 161, + "label_id": 73, "occluded": false, "outside": false, "points": [ - 43.162109375, - 178.533203125, - 59.51942683264497, - 190.96449996088631, - 71.29648664503111, - 177.22459684643582, - 100.08485507530895, - 167.41038033611403 + 37.5426108896736, + 44.316280591488976, + 109.0776273667816, + 115.21824382543673 ], "rotation": 0.0, "source": "manual", - "type": "polyline", - "z_order": 4 - }, + "type": "rectangle", + "z_order": 0 + } + ], + "tags": [], + "tracks": [], + "version": 0 + }, + "24": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "25": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "26": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "27": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "28": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "29": { + "shapes": [ { "attributes": [ { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" + "spec_id": 15, + "value": "j1 frame1 n1" } ], "elements": [], "frame": 0, "group": 0, - "id": 95, - "label_id": 67, + "id": 169, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 101.3935546875, - 200.7783203125, - 135.41603451246556, - 214.5186195856586, - 117.75044479388816, - 248.54123682144018, - 85.03638975948161, - 235.4556148076772, - 73.25932994709547, - 204.0501219746493 + 19.650000000003274, + 13.100000000002183, + 31.850000000004002, + 18.900000000001455 ], "rotation": 0.0, "source": "manual", - "type": "polygon", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { "attributes": [ { - "spec_id": 13, - "value": "yy" - }, - { - "spec_id": 14, - "value": "1" + "spec_id": 15, + "value": "j1 frame2 n1" } ], "elements": [], - "frame": 0, + "frame": 1, "group": 0, - "id": 96, - "label_id": 67, + "id": 170, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 216.5673828125, - 208.00537109375, - 252.552734375, - 191.6484375 + 18.650000000003274, + 10.500000000001819, + 28.650000000003274, + 15.200000000002547 ], "rotation": 0.0, "source": "manual", - "type": "ellipse", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [], - "elements": [ - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 149, - "label_id": 71, - "occluded": true, - "outside": false, - "points": [ - 699.3046875, - 102.618369002279 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 148, - "label_id": 70, - "occluded": false, - "outside": false, - "points": [ - 738.9471427604549, - 102.15625 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 147, - "label_id": 69, - "occluded": false, - "outside": true, - "points": [ - 698.337805670104, - 67.95976734549367 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 150, - "label_id": 72, - "occluded": false, - "outside": true, - "points": [ - 700.7550293506938, - 134.04215851499248 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 + "spec_id": 15, + "value": "j1 frame2 n2" } ], - "frame": 0, + "elements": [], + "frame": 1, "group": 0, - "id": 142, - "label_id": 68, + "id": 171, + "label_id": 77, "occluded": false, "outside": false, - "points": [], + "points": [ + 18.850000000002183, + 19.50000000000182, + 27.05000000000291, + 24.900000000001455 + ], "rotation": 0.0, "source": "manual", - "type": "skeleton", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j1 frame6 n1" + } + ], "elements": [], - "frame": 0, + "frame": 5, "group": 0, - "id": 89, - "label_id": 66, + "id": 172, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 225.70703125, - 314.6240234375 + 26.25000000000182, + 16.50000000000182, + 40.95000000000255, + 23.900000000001455 ], "rotation": 0.0, "source": "manual", - "type": "points", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame1 n1" + } + ], "elements": [], - "frame": 0, + "frame": 8, "group": 0, - "id": 73, - "label_id": 66, + "id": 173, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 532.6000000000004, - 300.2000000000007, - 533.2000000000007, - 391.8000000000011, - 678.4693480835958, - 393.13736007351145, - 639.866763142998, - 300.8837248764885 + 14.650000000003274, + 10.000000000001819, + 25.750000000003638, + 17.30000000000109 ], "rotation": 0.0, "source": "manual", - "type": "polygon", - "z_order": 4 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame1 n2" + } + ], "elements": [], - "frame": 0, + "frame": 8, "group": 0, - "id": 74, - "label_id": 66, + "id": 174, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 618.228515625, - 215.12753906250146, - 688.3285156250004, - 284.5275390625011, - 690.2463290244214, - 278.63711201903425, - 626.1000000000004, - 206.70000000000073 + 30.350000000002183, + 18.700000000002547, + 43.05000000000291, + 26.400000000003274 ], "rotation": 0.0, "source": "manual", - "type": "polygon", - "z_order": 3 + "type": "rectangle", + "z_order": 0 }, { - "attributes": [], - "elements": [ - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 146, - "label_id": 72, - "occluded": false, - "outside": true, - "points": [ - 615.6438723666288, - 128.1533203125 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 143, - "label_id": 69, - "occluded": false, - "outside": false, - "points": [ - 616.3137942416288, - 65.9970703125 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, - { - "attributes": [], - "frame": 0, - "group": 0, - "id": 145, - "label_id": 71, - "occluded": false, - "outside": false, - "points": [ - 585.0322265625, - 97.32024233915763 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 - }, + "attributes": [ { - "attributes": [], - "frame": 0, - "group": 0, - "id": 144, - "label_id": 70, - "occluded": false, - "outside": false, - "points": [ - 616.9673812833025, - 96.87642507954297 - ], - "rotation": 0.0, - "source": "manual", - "type": "points", - "z_order": 4 + "spec_id": 15, + "value": "j2 frame2 n1" + } + ], + "elements": [], + "frame": 9, + "group": 0, + "id": 175, + "label_id": 77, + "occluded": false, + "outside": false, + "points": [ + 9.200000000002547, + 34.35000000000218, + 21.900000000003274, + 38.55000000000291 + ], + "rotation": 0.0, + "source": "manual", + "type": "rectangle", + "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame2 n2" } ], - "frame": 0, + "elements": [], + "frame": 9, "group": 0, - "id": 141, - "label_id": 68, + "id": 176, + "label_id": 77, "occluded": false, "outside": false, - "points": [], + "points": [ + 40.900390625, + 29.0498046875, + 48.80000000000291, + 30.350000000002183, + 45.10000000000218, + 39.25000000000182, + 45.70000000000255, + 24.450000000002547 + ], "rotation": 0.0, "source": "manual", - "type": "skeleton", - "z_order": 4 + "type": "points", + "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j2 frame5 n1" + } + ], "elements": [], - "frame": 0, - "group": 17, - "id": 92, - "label_id": 66, + "frame": 12, + "group": 0, + "id": 177, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 50.359375, - 283.8720703125 + 16.791015625, + 32.8505859375, + 27.858705213058784, + 37.01258996859542, + 21.633141273523506, + 39.77950727505595 ], "rotation": 0.0, "source": "manual", "type": "points", - "z_order": 4 + "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame1 n1" + } + ], "elements": [], - "frame": 0, + "frame": 16, "group": 0, - "id": 70, - "label_id": 66, + "id": 178, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 459.5, - 81.90000000000146, - 545.8000000000011, - 155.80020141601562 + 29.0498046875, + 14.2998046875, + 30.350000000002183, + 22.00000000000182, + 20.650000000003274, + 21.600000000002183, + 20.650000000003274, + 11.30000000000291 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", - "z_order": 1 - } - ], - "tags": [], - "tracks": [], - "version": 0 - }, - "23": { - "shapes": [ + "type": "polygon", + "z_order": 0 + }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame2 n1" + } + ], "elements": [], - "frame": 0, + "frame": 17, "group": 0, - "id": 151, - "label_id": 73, + "id": 179, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 44.898003339767456, - 63.52153968811035, - 426.46817803382874, - 271.5955777168274 + 51.2001953125, + 10.900390625, + 56.60000000000218, + 15.700000000002547, + 48.400000000003274, + 20.400000000003274 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame5 n1" + } + ], "elements": [], - "frame": 1, + "frame": 20, "group": 0, - "id": 152, - "label_id": 73, + "id": 180, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 43.79357561469078, - 28.97564932703972, - 136.88880816102028, - 164.59188774228096 + 37.2998046875, + 7.7001953125, + 42.400000000003274, + 11.900000000003274, + 35.80000000000291, + 17.200000000002547, + 28.400000000003274, + 8.80000000000291, + 37.400000000003274, + 12.100000000002183 ], "rotation": 0.0, "source": "manual", - "type": "rectangle", + "type": "polygon", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame5 n2" + } + ], "elements": [], - "frame": 2, + "frame": 20, "group": 0, - "id": 153, - "label_id": 73, + "id": 181, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 108.50460643768383, - 165.35334844589306, - 795.2833482742317, - 679.8631751060493 + 17.600000000002183, + 14.900000000003274, + 27.200000000002547, + 21.600000000004002 ], "rotation": 0.0, "source": "manual", @@ -7394,19 +8536,24 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame6 n1" + } + ], "elements": [], - "frame": 3, + "frame": 21, "group": 0, - "id": 154, - "label_id": 73, + "id": 182, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 40.11789474487341, - 128.13260302543677, - 104.97083768844641, - 182.6914280414585 + 43.15465253950242, + 24.59525439814206, + 55.395253809205315, + 35.071444674014856 ], "rotation": 0.0, "source": "manual", @@ -7414,19 +8561,24 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame7 n1" + } + ], "elements": [], - "frame": 4, + "frame": 22, "group": 0, - "id": 155, - "label_id": 73, + "id": 183, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 19.106867015361786, - 71.99510070085489, - 200.8463572859764, - 196.5581221222874 + 38.50000000000182, + 9.600000000002183, + 51.80000000000109, + 17.100000000002183 ], "rotation": 0.0, "source": "manual", @@ -7434,19 +8586,24 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "j3 frame7 n2" + } + ], "elements": [], - "frame": 5, + "frame": 22, "group": 0, - "id": 156, - "label_id": 73, + "id": 184, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 55.147965204716456, - 27.993821200729144, - 354.91261003613545, - 129.34078386128022 + 52.10000000000218, + 17.30000000000291, + 59.400000000001455, + 21.500000000003638 ], "rotation": 0.0, "source": "manual", @@ -7454,139 +8611,165 @@ "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "gt frame1 n1" + } + ], "elements": [], - "frame": 6, + "frame": 23, "group": 0, - "id": 157, - "label_id": 73, + "id": 185, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 55.67655109167208, - 27.202181529999507, - 314.5855129838001, - 333.9054008126259 + 17.650000000003274, + 11.30000000000291, + 30.55000000000291, + 21.700000000002547 ], "rotation": 0.0, - "source": "manual", + "source": "Ground truth", "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "gt frame2 n2" + } + ], "elements": [], - "frame": 7, + "frame": 24, "group": 0, - "id": 158, - "label_id": 73, + "id": 186, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 30.75535711050179, - 51.1681019723419, - 245.49246947169377, - 374.42159963250197 + 18.850000000002183, + 12.000000000001819, + 25.850000000002183, + 19.50000000000182 ], "rotation": 0.0, - "source": "manual", + "source": "Ground truth", "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "gt frame2 n1" + } + ], "elements": [], - "frame": 8, + "frame": 24, "group": 0, - "id": 159, - "label_id": 73, + "id": 187, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 78.72917294502258, - 28.6186763048172, - 456.07723474502563, - 214.25403320789337 + 26.150000000003274, + 25.00000000000182, + 34.150000000003274, + 34.50000000000182 ], "rotation": 0.0, - "source": "manual", + "source": "Ground truth", "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "gt frame3 n1" + } + ], "elements": [], - "frame": 9, + "frame": 25, "group": 0, - "id": 160, - "label_id": 73, + "id": 188, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 40.30229317843805, - 20.90870136916601, - 277.9420801371325, - 141.0943407505747 + 24.600000000002183, + 11.500000000001819, + 37.10000000000218, + 18.700000000002547 ], "rotation": 0.0, - "source": "manual", + "source": "Ground truth", "type": "rectangle", "z_order": 0 }, { - "attributes": [], + "attributes": [ + { + "spec_id": 15, + "value": "gt frame5 n1" + } + ], "elements": [], - "frame": 10, + "frame": 27, "group": 0, - "id": 161, - "label_id": 73, + "id": 189, + "label_id": 77, "occluded": false, "outside": false, "points": [ - 37.5426108896736, - 44.316280591488976, - 109.0776273667816, - 115.21824382543673 + 17.863216443472993, + 36.43614886308387, + 41.266725327279346, + 42.765472201610464 ], "rotation": 0.0, - "source": "manual", + "source": "Ground truth", "type": "rectangle", "z_order": 0 + }, + { + "attributes": [ + { + "spec_id": 15, + "value": "gt frame5 n2" + } + ], + "elements": [], + "frame": 27, + "group": 0, + "id": 190, + "label_id": 77, + "occluded": false, + "outside": false, + "points": [ + 34.349609375, + 52.806640625, + 27.086274131672326, + 63.1830161588623, + 40.229131337355284, + 67.44868033965395, + 48.87574792004307, + 59.03264019917333, + 45.53238950807099, + 53.3835173651496 + ], + "rotation": 0.0, + "source": "Ground truth", + "type": "polygon", + "z_order": 0 } ], "tags": [], "tracks": [], "version": 0 - }, - "24": { - "shapes": [], - "tags": [], - "tracks": [], - "version": 0 - }, - "25": { - "shapes": [], - "tags": [], - "tracks": [], - "version": 0 - }, - "26": { - "shapes": [], - "tags": [], - "tracks": [], - "version": 0 - }, - "27": { - "shapes": [], - "tags": [], - "tracks": [], - "version": 0 - }, - "28": { - "shapes": [], - "tags": [], - "tracks": [], - "version": 0 } } } \ No newline at end of file diff --git a/tests/python/shared/assets/cvat_db/cvat_data.tar.bz2 b/tests/python/shared/assets/cvat_db/cvat_data.tar.bz2 index 2362ebe481a227a1046830b397c3db77b593b0a9..4976f9dff92e3545d2e8836ea3e2895f9a61b3ae 100644 GIT binary patch literal 89057 zcmagFWl){J(*XM52a3CMkmByn!KH=b?of)m6hCNjcXzkqQna``#VIbu-S6-Je!Fu& z+?`CalVr2m&1948P9$_K1vtcYXw`J+qbk{8XD{FXf8j4m{ig3F9%jt~w8DJ?hkZJ) zj@U|1Lm~)(LSRrK0P*M?OGXnkkH;cO^f@N8RcKQ*>ngU^fo!RygJ^4gu!D7n&+gpA zyG5(5?ToCZ^XxuLXoK}$tJ6$)Bd2L0vSa1Gre@>JqvqtB#G;O_&Q9MfzJD;^E}|Y zJJQBu=N8|umYBmQwW->|DPJE}wYB!s6T~R2nn#)HgSN99k4l9>i3J|3{Pk>&D(=2W zA;*v2nL^CBU$vO^=4xiWot5fK^{SKX6qx6f*3ZtYGdpyQ?4NwQ)|(w2&SZ4o}zq zB&IW(sdHh?%WT7(e0VMPN&rsvHDn_c}M>|EW$!=L3lc;|{otm}UU=g)+^kM2jSNe)-7EqN)N z`cBYnvaasa`d1cgm{vQC3zn5KKe-JE3@jGJd|I^3*t_B~o?Dl<#kniu<)duH>%007X^;?RDkrA?#FV2|cT z%`kzJgus+Y;@((V!hsQkXn{0Z9OU4hAQ@N=ZuT@!F&GK}u*NWiIKY3ciiiLV zJMbC4hZewrHwOJTB7ps!`@ftR4gwpb0HOUH6_AIh2qyytKL1C6TqwWG-EWm=m0v=e zLCif25dYUv5gpq9oT6MLb~&&ZZA z935LFS!Lvu%vm!0C@{^9C$e3EPtZ$3TP6}o94cffm%-YuLR9c zi$KEV!t)>i4y<|qOk6)4&0vIQe7*=A#FF+uQKl8i0}ugXtS}-jDh^^}T3BObXc}T7 z{B-|F0Hj8B2c1_4~NCJj?(@|Gn*6GMnk*XC`@6;UBmDlHfBx zhaegm=0oh+!q@i3=IBR>T<5+l@`8>t`r9lUexzjj7-Um*N9j?IhPUF#{85{+nok-^ zCb97x5;Jd6;nxhe_sdtPW^=L6OgM<(SZgzX{;NCBV(7J`R3px_>hRveVn`B6AFd1@ z=Gyx`=_WVv+mE?99dL7IkAcRr_OW0WQ{C~M;JU#T%lY5j>SduwQX|5&n!)EZ0nEKd z0w;4K&P$Zy?9IE4>c9TXbn6xOQQPf;mqod2VprX4Z(mY}`fkgSVW-5pA3e{XC6bqd z>q2ka+rGP)(DX4<@~hlOcl&wv8HEnmjDmh$<_<`(8)8cIB14b^ad04s*lBW9-X^{WQip-4;1BSSiI=_TZbH$PH> z6R@ZzRqpGtN1$bKABTHf9$Y}SW~L;f=ab=Ph@o{Ds^fJD%6;j$=~!4sE@I^#VI0)4 zR%yl0mTXv{A`agM*==sN6f685k=MOTROWf_aA2 z(AHgBCNj@p(JCi=_Fp}QmOElV41?R10{Xnfm6qH7@-pi%;)!EJ5tF{nG*^xA()j-D zjYODq-B1WN+^lxC0U8hM^4T`95MVj`KS#8$f`HR zJ;uzP`3Wu(Ng8T)5`{lJe9ZH&3OZYU3j%uje@a9oz1{$eRD{}Sj&%cuq-A-LV31W_ zsSz_IjtPw3^juag?J8BG8IA0LK(>S+%@i1+6Ci<0r5tB&9tk!*v#yM+>DAFc@{DEe zy3$^Kt<*Qt`YMfBoqC2KAIpDYuhh6|U$=N$E$(Q?Lmt|gFUO^8o8%;t6Z5m0=Ia}7 zaGV?KG~<4l!6z87jCM$qy4Wu9mrj_Hy{@EbBW5S0RVijtX{23Hlc<$~3_S9jils%^ zw2dwF`61k#?)`jH{8eqpdF9_Q*Qtpb+d^NT=;WV{itc5FVdL!xbT51{TQT`oD{C+O z3D5{7%ZPbgrf8_>{jU(o@5E-%rztaRZf`NY#Bq1baS_l3vP}Somec`1bdegTr@2-Y&tlm+tA-OE)6n2Me<5#+{a1 zKI=c&DopBfm5O)S_HS*@7DUg6Di3t`(kaGSpWk@acDCPLp`6y&OFJ>Q*Y!Rv>*BAn zZ^PZP-+wK2IT1q6ztH3%gp+0w2_YZh%{cx%bq`J63V&Dl!6a~Kz47-Gdp-q|p07;) zg-atw)W7!;r}s>xWSuXdYt~zoi3k&xSk*&bND*zk|*nB*;HMlPw(wgxQJ?9SVZtlDEw09gQrBgnbP%{+is^v9hommX8 z#Th3)zMmNVjhtML^{<@ngMe@>^)o(_rE>7LPYlY6v;W83{Kz5il^U9ObK9xu3PF+7 z?yy7wgWB_ya2i<~L$2thKJy%}#yJA9cBA}f$t#h4zo1>A*C{LqbkP=3;TA{lu2*-q zh{}!2-#bZayE+A^q;bJ9+R8q&Z$sA~brQ=NLHwe#1=kj>_)^>@y5YOljp}5m8v#mX z1)=}K>bOAg;#i~oCp}8mu@>|+H|m5&Vbpnq{JeBVXjjEBd0xiUW4I=0f?#REEmbNs z74#g$VH5HV1R@$<6wS9lnc)hHBXTbkG-JstkWopukxKQtCb0-3vTXZjaS5(7X#ABl zeDog(eh;{pLSG#gmQNY4TEiIF_*+(ndIK@0#|NPq2a#rptKoxsdBx?b3lM`Fk+#n;D6ICIFtB@a1F@qhov1lL#QO{ z4tpi3Rl2^YdSAz;6K#JMo>M~q-taQ9?UnGzCqM9{F2tNnS|p6&{$u@>+f71QKbkCu1OU~`gUp;iQoqdy=GzP3!#Mjrygjz zn{Cz5wNq@BG{&S=O4^`TqyDOir=?X@Zn_!?l6=jZ_ErH)e(DF|h}Vr2W(dTT22M(o z{)=FgW4jDakeLXST?XOOShAb{B_vad-7e3`Bj*3Ok^WGtpF|PR!PSudfL{aN>`=4d*=}c7Dp#j` zky5OKfhmRTx(HFXp1S?%l_p#KA923tj?sNS_2Us3>p=p60r}(qOCY92^`F*pQ6djh0H^f6$UxwpqrFdN^tF!F!Z zxBEm6u?`55BA8j9<#xAn7%A~)4_oy+kQ|S_Qrzj!>gmhDo#bBcr!>L&AMX4&u#P(! zf|AW5PUgdP-K?t|X5f&6rZ-1Vv9NIM&a7CLumjZ7BGhpuvJ~;GC8C7Yy3m4gHKu!< zFC^k-#8DtHYhZd+l&=L9aw7^zVEn)8?3AwfHWCbS3F7>gk+JZEA*SpqsD$ts>d`97 z3P9=;9TL3_H`KT+n}wu@hbU+vH#(MBNu0LsQmllzwsQyhL zu;9wn#dG^aa9&A{yijM%eT7TL-fs0b%LYv^zc~gL>-N|$LoNhO8z!Ri{vGZ$B?T9J z$tWE3@6s_FR@s!K1)utb71ZbGQm~#stT0<=Fo`Da8U^_Z|lcVabYdx z;H?=qZg*p~oH*c#m{21BCXgQPu1Ptun=*T-@mU#c(hD(-bXCqRphs875{|Vd&}J=dOG0mpXoU-d%!u4 zjZ>{OnxxZl9)29N=VDMIl?<;`bBUT~-b>b*5DK$50r%3NlPLD0n)iiKHcEzmmb6qq zZ8hg!l{7Qwz+;7%G}lYj>5a@FrE);Gzt~_jBP?3hK+z4g=(8-sVvgG8)N3uG`loa} zKg+tHKdU-!v72JW`svayLY$gz99I8va+}Iio>ZT-ZQJxL`x^!;V@b}3szC#s$Z-p6 z`v&dI-xD}JP9>1cO?<|o7KQ>v;gtSfO6xi9`k=9u|?4N(9an9gG3oRD!S=uPyYNtk}tBW*-{HT`DGarYqw?H=G z!td4JA=H~cjXB{^6*;Z340b8mWe!t5OwJ73`R#N(^FkmUNkP zk%l%qJWaru@rrGLVFiu;LidcW|1h*U#x*Q(BPs$%i|P|6vQ&IdKs^oTtb{=VUNBQ1 zpDGSCfyY#Cg2q%T?68l`UHL;YcAomJwDey*y?g-wM7PPs_jxZKi)jHy-X|WB1{u3A zZw^6g5fCSCkeD&}K~SzMtuQ9ggj*1cf$=OFesl4@ktZ|Hov2e0xE2_PX-Y3=+gIV% zM5u?G7s$G)A-x7sqfK}=xaZRA(VUKgcMO&kVGPXWUbdayFAhXaxWCtcVGG0%)%I{C z_^JNEhA|Hh_F>|2yV;m-76QAEEw z$xi^~5QG|(TTGINjgPDRRT+N6%8k*&K7`UvLGWMEho>MXad94an6HkVD4J!s2q2V% zGO9(0@+;;FZL#rq9)f8=a7AB*jdRLX&dV1EwLDi&eZGjm)uX_dWK|S&3>byH;W*0t zA5TK90I8iZ%AdLlmZzlVd?*iEPVeQo`UehbbWo_jhyeuHJT6>mm~WGS!EIq5WLxYP z?(rp}GR1}^i|VM!r4c^P+$GI(Wm|^M9X|u!!X{|mqOy^|>t~@P z6-Pb|-{_CuEV9O;Vy_&bVea|W9+wO%g|A?Z*BDG43dB?OyTtsYwYt!X&8q2Qu6PV? zY}XRzl#8CwNT3CdfdFDTg=p`fp`|BSw>b$&3|%WApCY#vm#J}U;9N|DTf7U=h{0me z4eU>#*lx01q%SK76hnZ;GDe^e4pE_IE-1<)j-ghqXn2{Sr>EnhA5N$j)edKDWK_m9 z&i8{SED(o7phZqO0GRz;F~iJ?5`Ga z9STDQLxl~E6JI?W{Z=(iLj?&Fmkgq-{57MjClSx~jQ7Df?(wjZa*PpH@@prEn`wY) zYdDbrb}cS1^cq_hKmKPp{IZnVR}FjsMZZj#zYHGUhR6B@?=)CC6Dn^W?>3Ony4;vx zO&^Xe`$o|L{D4rSzU00c%Sm5z#D*RHAz#`v&xw#D(Urx5QJ4THYS0h~P*E3v7@n*6 z;&J8Z_2JPypk?LeYb82|HrDZrrp|cE)IlL2`EEsz!G$Wu7|lUd!(jMP03Riv@Y{rS z?A(I_FUuK57=psYb3qf47Jb~2nOp^7m}*}Ir8xFR-XkLbu}oBu#`#;mbD9LH;y1z3(6E|! z;8w@#8k`xN4dugAZ8_XKh92@EpBp)2Uh{y{5mNknkii&;)cy#9f?QJmMLy41L0aeBiy= z_^ZzFX|3;AXp8YdA7`zkfeE>_&Q_n3T^32`NeeWwYa{#sJrnoGH+>3jW5E1T5 z-{fR4zB;`XuQ`KQmz!`_-Z!T}n+`L~C~Ie0K=JiYqOISQ`-C*Cv9PWNfF-*e z;L~2p*QMJm_-NKXuyCR6Mk%A_GAATXI5SdEJ6!{8^ZUw7$ z!`|`e=&B+pK>7A&E4cTPDA(+_Rfo@J3Q-6QaJ^5j1$XF!qH zFH};tffSF}9Ey(4bo&}o9I!vi!uXq1Y__YM7_0Tx`W~F+<`8LkpiIh~q^&Ob=^tPq zSG#x!;<-yfk&(uA1k3Ql4!$!!3=mqHN`^Cnv0C^+PWs9({cDR+u zQ5uX(%Fdh>dS(N+M7kA9#kWyouoS=0n$dOhCBnW$ZYL(drx8K$qtVH=X&ZXE*!fxGcRh*73ZZ?0aQ5jfH{S4MR}K48#p$rQumoh9ZRCca4@Q{p>`PXe~Q*gf8o{i1;*vf%8^wo&?0 z1Ct_oWqXwpN}^ZMg_j7ae4gC3R5mWs2VAsoxz9cv@T@9dN<;63Sxa@d?AmR+8QPb6 zWX%E*&OGascU!97I!52bmA*#E{Tb3_{>RPv9NL*Wvr190{HIi8^Q~H`>jfU5!Wfi} zKlWfrI`AnyP$X03nhbYW;ZrFW3lo#N$;*K|lq$;Oxl$tdM!} zEEdTea%K18=_D2)N3+^ihKp4pGp7d9Az;foCh!-9E>kf((8S{+yVvv`ubD;J zlI&tHRrJDW-rynL)((!Cdir)6O!|ku3P*yA$eG=vzyNQYw8W zJB$#!BU2yH+&jY22Qx$T@pN+PgDS>9yZ!-`hyLk>djkl9Py!H@pR*iB$<9Ump+B}( z!5gc8n`kb*&Svkc(B0eWs<(r~>0v=6bNvh~n5^5V9dALeV)ALa6M%&EJj%xNEfh|% z#_e@XSng$B_$rv2$RcH2P5cucGuPSM31kO|nzYn+r9jzJ7N{7AR%VVUF;yOJZXO=} z_0Nt>NrAOqmeg?otOdLjD1^!P8^XbW8%IigYvnQNA{z?-HKVoSM=v(QiHP4wmc95U z0tal6|M(4MdaxdaX!Y{N?Xk?L^3+&v=BdWJm-TJry5Lmb zYF5d?(frxsvxmRU$_6U_^6VPzhp>X>TTk$z>{31@>b;Hjzt>NIT8!SHwie%-%rdEY|!BT7`uyH;#pc;5Vg7!hUZR3IQz{-I1GM--0 z`Y#@KWKA**=kR9(FOU?|SYxjJ0gPlTB>|%0^!nwXAg%SOtpx=1UU0)AMz-5( zRudDWj9q`F>*tn$t;OAJmA!5$3*s`1sFHkAMHO2sD6%Cvm6#3Nw-%;wKp3=IptPO( z3`@!M2nG;KTLbk23ak+Eyo4rbVxmeXc zG2Ser9cxgdrcdK3(rv*3FwW6`(lmT`6b~x0{OKG8g=boRqtI`5&Onc3(%@G@?DVTP>{w#^i`#9%h8KvChL_Sq;l*3?7MRa3_I5b|zu>V}O4{ono5{>~Vq| zO1|1~XbXL+ToNfyL! znQxPmO8Y&EwU^-2F57i?E^%MpA66W!Z(W6n9m;-A{e??g!KTbnX)#Uw1Bsk9S;Um@ z%BQ&H+@=umLI%`JNDcG*2RbQ#q0RS@*L+U3Pyk^)oOj4=b{7f?^P60{l{>DJ|lwFXjuP}Yei_#ebefR_ILPGRGpyf(Oju7ncQJ8Eu-xTh$G)X zkX_$f51qAZG(^Nlkm1htMQ3AcgqE~-{x<;10N{zk!eC%waA_SQnb5V6_eqQ-11c5Y zon35O7mab7U8^2nn~WATH=`J&)daBceg@bGE%RD{;DKcD7gi^|o-u)j1r=JxrBfUk38LL%1sAnY#1Sc)y`~QLftH(flASmS zm$tLjM?Jnz3iWJobY9V^ZYTBaeJ{j8%GPL*|J1CM{eRm2zha}EWpDp?-~H;f9tD0G zlfAx9wiy5Sh8>3ve*g;$yFUUf@kmZx3MLf1GL}VTSpFhMMLNWY&=N7ZuiD6W=V~A| zaq{iE*UhQt-j%KK1Ci77e{a$g?3wAt_Z7*3*a}E+-5U4e_ZaRQmr`$^2m|@I1YNJ0J)?(6z*ZoB}rFyCf;s_o=YLD{O_YWTW-`tC8uYdB84|Uq} z7plw8Sr6faIwsA`O`_X#ah@b~62v`E`SvOAooZ%d1HecdQAQH`i?~lQhS2DS(Zss%q3kTO>u*ZJN)cztx2S(fNh^TB(F8BKrF$!mBB$zZ2X*Jfg0T@H$B z21lO4S^Rf%`UwPJGak9#)z7Yt5+nClb9$%B4yF3vD2gc^8!t$~U;1X(_>$a?|U;3uTv6_8a2m=YCZgLe4#2R0ch*^y$Ukf|HO+DL(dHL^qgh)bhkB>k&0n4?q%|Lp!u7lO zGs`deW#x8~C3bhSgleTeo|l*W z`u0Lez}{>B=NqsfFv0~mgz7~m5YMCFi*144z+W&m6p~?t;E{!uHrrUgbf;4?Qi|GV zSOz7d1m@lLhM@#~NKp%3c@~{BeI-L9I$s)kCLK0(&qY|wg}e0?HVO@fglRQAVt5St zMrig!1gCQd4#Z%7?pk*MSa8$qmCc+jADaYAHpu!RlJvXBP*PtTJVATGx>1!d@Abv= zQIR1X7uxKF;hps?1x)`1t&yg|mSKt-ve)Mg#7%0H*2V$a?M+W-*KT{{<8w5&OkoG( zfa0G2lmIt>{a}{z1JVNuj3mMYD;8zg1SHSA8x^Z?)G!0Mr-NZYk1>39`Bpyb8H%NxsYq3ogq(B!K0EVD7~EBf3<(MdSpaV#!{e>Fm5?N z_jKY&?Emwi#5$2%n%@;IA%yYmpVe#OB}JpLj#I)Pzq5D0eP=3o^vmSt@-em%Se#r| zJ3Kjfrjo*Z!%?+wBUL-ZrbkX3q73Wi+<~V=u8Feezd<%{)AatR>I^1oNdZ6#16;?K zY8~*B@jWuF@{Psy?s&$F{q41-H~QA-2XX&JA90aZ3?zdSLw+Q7KyeHfuu-b-ll_~& za6JJkN`$>ZT*7!ce)IFEE?UZBeKb)@Ga)4DA+W-OIn#iX19kD2cjRT)mm%~HQSuqF z`3BojzLw$Vzi=BOiWPl1c(8dTQ0OKT{HQ(2N9b*)hi#i;HNm^=CVURa?RZe_-$N`T1O2*| zi*Wr%XuMhCFB$O5aBIW9&j-XAc+Y{BlaXz#2Qk)~{zq5`eT zd2Cz@Pdzx9gjB)vNAffYaXeH$8*N7-G?*ZcD??=RV^A=-9-Zb3HR~q zBxgy$;-H&>+?n@?X3+L=&O%NrlRCMnu+SG5$y*@@lP~g{6Q-AK5;$!rRR9hMA(H35 zl?vXl@;zXkEmgh?a-&W}sN{s0Gk@M)4GqIMI9KEGyr*;wgb)n2+^)*~c!PP5l?!m? z^6h%EF~Rux%epU6lQ79C9OInz^$Qe6&wGfX@QjFtr*r^5lF<0<_uAwQ=^rZr7Vp8e zH#FLbMb|FP&Z6@+-P@(5eYX(XC%1~(DzMez#N5f&cWi~vdJ(56n$I4Y|2{?%jhqO$ zUIADr{H&(I`tj#r02CfAu!{BEY_g>KmX}tS5NahKuRD3r-vx^9K;!j5YxhHGMka;* zkEAS>Y3hKBxX*0t2y*xd_-_||q(*9+kfd*{>zl;*t6xOqTNeF$Melipx>G|ku<-Y zHX-E23nl!V2McTwFKATlbMf|in9XaTVpo>31go_HxIeP6(4Qu?akfnMgVi4D^hH#q zAh1XsOn;b4^B^^FSqFuhYu$MqeT|d-J(T7*`3YtAT@uNs$>haYb0G$8rC{(n8m>aOid) zuX-ENP_w$J)G@v!h@RWE4|}AfAx75&SEPYJI~XQE+hZvYuDfGXz!qwyc*EQ|Gx*dO zPHhFg7O;)gv%IK!BPFP;w)WZ$)@|K)NUpN#UZ(Bnf!T@ld>;}>=d7-a5i+mULhC@* z;=mdb-0oOu6m}aF(Fq$7QuCYu!njka^p7f{nT}LwYiF-zuL~E_;Q@k7FA{X!>=8_q zTQri>ZwzAi-TQ^&$0p(_ZFk({LyVsBtzTcIr8U;#F~zuK%$IC;u0IgNJ=VrwbRA%V zEk?4V>H+`|tPEH+-0Cl{){M2GE1u7+)azolRw-JPloTY0+lM(*ZIHFeaG#|~nTnD$ z_-ht0QETC}IgD?1oj72FS6rRfD4olt?Q8GyDHjz#6bmtnMeFO@VRX=a57Wn)Ll`VT z;|AM@@D?2=gb%)}CQ(Ju+4W4Hx^uFk#prut=S1_jN8)2-|DT7}^2e<$g*O>K%C|AP zkK!V!ZeD2IV)zC#_XX#dQ1`!=xz<}{68{3rv*a^%bnH+454L&}avOz%-~sRl*Zpn| zBMq{fh#9JvrY*Wae`=VeGT{+2p0Ab@KT@%!r5=25@F17nE!-GJp>#4`KNL-nsdBXT zCV!Xgh);63zw|@GTF>u@#+HuW7f6S+5SpEdeph7cE$sMbL;3vl`mR`By1%hytiSF3 zE_n0MI@1c!!D2ariD<8o2gzrAds*QJ0^C?~rVh!jE*3uEY5JC8JzwF zGm@e{BL)O{D+-|7O!IZJ=CebyN5atgDu^TSGk@3qvgUP3#^n>47bYI-dH8!Wf5<2* z0$aoE+o@?M6W$Z+X-Vza{G>1?>AZi%I4^girJcn{Ce5NjmM zZJ$o()sqd+HzAUTqPPV4EkBznuBw-VDyNSi33y77cy<0jXs#rVP8)&R?{=&B*LXsB zDb?{+c1UhHs`tc+$Ly)MnGh|22LJCpHoGis8#DMnAMOv&mjM+^{ep%LK73A(o`l?V_pet$NDr?5JnCg`R5q_RaQFa zFUj(-r1>4c&l&9g&JTFlpSMt0{LU#=-zeaNA4r{2OXen))%?Aw`h2%X+D)?g7TYp2 zYsl$~K^eY(r=Nndlm2qoTd?;Mqn9(gN0We=@ZVAX|4mAjsM|eM^L&c7ZKs32+r-=@ zlLS*EPH#V`0QE=uIJvlx15$u8doQ;B<kC9*#%a%p37FwUQq8?Y#8TiRD%`2nN<&ZnhGas6P93)!rJ~cWa<7nL?UO zxsm`2IDk?r0u)0ZhJKH6&B4PhMeSxaCANv~4dUYjk+7P?qv%3L^cpH7_#{Y3j|zbz=)Hy z919y=tV@I*P+9h`lubI#Qf%XKGEp*4w91%trbV;SH}M*Kqw#P80u)XWA{)C}cWV21 z;QXsXJmaq(9Ux#{b7adfEI?%~OPqRF`6~CkU3BeF%Zc-R>3YoX z7B@OS_YVuJW&Lb-cOt(JV*hMh*U}rUe9uP8x=OC_pF1VZwwLI<68)h(VZsvd%TF5s zQ*VDLJfQ-^=fO(`pCyKTR`o;odsZ6Y?HEEE^fMvgg)Mpg&u;CDM5{Gj`?ULxFFg`M zO}4%*9wf=xg^@2o5gq_W*C^=(O$`?R_ z{k7(2O_&Agf*3;ZzXGOI?dm1Vr_4pUt@Uy81^x!vII(ae?|-uwnlXgW?sOpZRu z+Onwn`;qC$j;d2CH)#%U58{hvF@;NCA_YBxPV}(UV;Ej zcqwLwz^vTRdOsQtt*79FKrR28=~2I5qB^H)eV5%;#gmMBB5<7T4u1B9L(w6Vx;S7= z1UXQS6>>^|msHr8UpP&enR^=aAD5rlhgD@LWpx377-=-+>Axk-P$nH8wvP!GkkRP@ zoc_^by1uk#F$+&!u~ihQ%$JZVM^4)lJ%Y&4F&+=AsdsI|M22OD_Lh4grC9NOJHwQQ zAMIn0o3V6{4_inzffe65lvO3*Az?q7VU{5AslR{BTKYYH^2*)6xsp776_{V`%- zGtH)8H2Z`z0vXhLc9R<5*i6#(7U$iMrvDB z-vf)=aa?2y7MI6p>*NQ);Joup8s!EYo*7#Xq{gI^NR#8`)2HMcP{r?~wr zV6U`Pz$>UASqTMMv|N>oWki)l3Y`DmoPBz%@=5h-K8k$s`!Mmglhvn&kW%xm`jux;2he_v2KPk0XE*v?mC_c@_={bqfjjyQC@}I1X6kL;Zfn|8AGofY*c+ z7Es$OAH;V|+4M>4<=Y(IFi5Q4Eyto2M@+IbdHo$Sr+;;&wcvW3_(xP9HvHwke+MJ{ zUpzz@OPh4>WYivt@5~PT-UzdK7$+#luVgjTw04v_Ev64HTt4XsiMH;#<=$9w)Yj=P z^N2XU@q0Z?t)7e(vSE=F7M6A)PfW&YgTev`E<0zK(Ka*Q-b8fi*{Wp%ztp+U0%5RQ zMebloSY&wl_}IxqJf|U#LV~z5@%`Q}jRCt(>WTuHgP9*HRO)emxs|3o5w`X%!85f8`F=Q^tadcy$d`=KoMiN?68-O2&u^#)$F+ z3-k1JTFajxGZ>mdb~8eD6D%x%bfyfa=r1Z5ppb6KAi*4uo?G~CNsHHcxS2|Fez>q` zMad+*?soX>`)#OqF)PI9=%}pZCRaZ+0Ldy+>vXRE!PCbg%& z{bGUka?C&DUAaDK*^tHXL|Kgl-A}Ad>gdcl%|Omfn~o}*KZ^;iGNRpD#W|4Jp)YYCS?{#J zIFH*%Pg$8Y^uWc~I=L++=(J9bR^wOISvNRqyJPUFF)pxp1b^<5C;Zf$rW|x8W$9R6=u5hVxGg{mZ@&Zb2jI0_X0j+4+~?#1W=Go!gmw+PQntHPPlqc zgp=s7E)TgnLcNiV2;1&vvHrW7L)W25aeE#V?n4#ffv@i$x!ivI7dp9*^2WfLmiX=b zX8_rK1r#RvWY*2(HGv@}E(Gq(cvLE*_^mr_uwrA96kGbImem4F@Il0qjxN~omdNnHa~7LgZ!UbzmbE}W^SRcxrzg@+P2p%4^61w&;P6_F| zXYwd^$vz4wPA|9FmP>6@!Rw;AWCzZZZ*;p{wYmGPVm(rr-eWUE zZloASw9$STNri1VukQu|IzA42ee(nrgC?fD;gv=_r! z_wuUA7!$hYcA`zu^WPoz8dFu)=)3C?-VtBb6isV?9Wuz!yz|8Tqk)7le$=R5uV4S_ zhlABWYUmx17%x0#nbPeg);$xgwzI+7?_ic^3#%2+Ea3foU_$PQ>qyqp&}76wTS(qe z@{nBlR8u%u^ED-!cX)9pKQva~`c{oB(SL$;y+1h%rI$Ci?3c6mFRm5uWa(4*rtz+c zRY|cM*>KCy&)HJ;2W4c@rwxV4Uk(gD{9F?{Z~o>}o~>Lt`EFo{fdzoj*$SS1ojaa! z|5;PIGhIUn|BAYhiw2V6!$a%Xn_i+;jRNe9J5cx++e3^)E(S9Kl-S-^R z#(WT}4&?nnZ;U|6-fam2AOu_=u8VGU|Dm?AoQW2MmH-uB8eILg{~2fOOYE#&`KNl^ zH@4>-%r4cBT8ZIfq*-}k)XakXiD`nq70K~7f`|2Kf-hd2<+O3816^}-wQ7MTMj9-N z-26jV9p9bVTHCh_QoF%{*dC&&QGSxQAU(EKg4tIcoyqfszg|VR@l&Zq!Y7*4AAW@$ z2L)8&IQ_R^M3un@n1H}WZJD$S7fEuk7%Gjrx!cd#%vpsg0Lk>Z0SiIOfBnB;Kd=YD zB()pqsx?SCk4+^1eMNM!&Xaz?|KljQ(S_5<`48Kv(LP;c-v?%)E&D##4h^nGHhsEs z)S&5A%;(u{q?dmfeZPCk z39ACRGqJAoriYxj!o61^{sYBMS#?p@*S4=ojPwPuj%<~90WTL&B6mN5x3URhc8YFZ zLT*cFe4GyLC;E}$<^KiYd2Rn!l+iTE$bo5sz*VmZ`5(Vvl$EiOQ79qBh`5yEfyyC>$Py|aVO8j7!zE~_#KU2d z2=gT{&1r~`QAHRN{{e5yC*VJI9E7-_qW^PA*>fWXDgVqHj8I0A5Rdy0XprH9!7B0+ z{{!(j8cJ9R%s7w)0&WsD2x6?PfIu}Es2Wd{gxl3hM|wR7Q#bfqS$&={#43gg7d|2c zL=%V!l`8@VfFV(Y(0LBOG0HpD_;MXYaA@r4E$5WRi6WL?pUw$t{}XM~VN}2=(f}&& z@)1GMECs_9KlLoB+W!M&K%2ia$b8(DwFYNUIWt^|CMK$SPgJdgx}%5`)F@qI6PxQt zSy9)nNv-vXV@aGK&a$ZBXFWh^)i_#x!&s%%;L%h=!Cq zvZmXJO*We7LLs&&O@z2gMw~E)GT3w(EsSuYARuuG@J}*Agj-OIwF99<53w>zQ3|9L z$p*Ie_d+hj?<-rqmY@43`b3*n%5v)DYQ-aHU1-q6n0c1WUkpjS}!G zhlzPEi`fh4RJ&BJseD*3YU?Y~%FR^kKD+VW`}rQHrvI&fT=@77IoI#qAMv&Qb5$w=F3YaSxshSlO zg$o5JiQxcHRSGFo8;qYLUb`%USsSp80V<+Efdn-x2!L*G4z5P;j%Q3hu*<{efz#(* z&;UXp5S{zCSHDfBm!G^!@@x2=Mb1Q=!y-1jLqEht?@MEv{c(B?NfLAWlf9XJ9!nyU zM(Jnfd!TyBKE-UdZ5r7s4NRlg{Svr9fEp%klyztXLw57eY{-~-SX+YWTcZgM#fMtJ z5ddL?0T2WcY`1`bFV);aOoLt>)2>yXe$vAj1U3lJ0K!4)+#g0!KVW9^5ZpfJW7e7G z0d;@?z%Wi1DA>*JnxIc|c>m9=J06SxB4r(*?6Tl$Der?{1Nw<-+TVxhN5PlqPyiLi zh{5+v*)5tux6BOfewD|J*Q=jGP36+K1C-~6!|AxO`0Jj&GZY)7gQn^t>V6j@rY!{qGv(`% z&X%0lEoNY&NQeMIqyPaI@9R{V>_Xu7SHjJezrT5nKyPvP5EjTzhe0BM0RZIR%Cci! zzjSM>m~h_uerUb4U;yt;c2D_l-~j4)00}PAG;GG8>{CQq^5xzv)_+9P?!LubsE+`F z5^2?ED&D(7bDLA8gQn1=Ox!~JwQ$XcE1glc*z&U6%&5fcE;f7l5$dbE={hv&J68E? zUV2nP`_y7=#|vvEVdDGetugoa$*+pr-m4M0$^s5>*1!-T8dxE%k!mRh7UrsjN7H1@ z85ag?H#p+VJzrWBxkY;hckk&C1X9n?e#EEdPejF++ZSP#tJ3|b_E0HWI`t$sWtkA9 zwv|UsQUCxJj%%XPmIwj}e6i3X%orG4lf?~$ZGZr1VVwKf?VY*;AWLF&z<4@!a-Y)_ z3J3xO12w&#yH=}r+AMwRg#Fs3yjuKCmITIkwV=mlJ2U0cv{Nk;QhU`UUY^0fJ@mZ* z%$rNND9sJ_?%~axCWRlbf?Y-S?9#fIFF##%Rk&}`cfMEYjbA;c1n;U}o2Puk<8hHa zAP^!bFc^+_QmvrzMA_!|2HHIB!Gu7Ed3ke1(?|dcwxlSo!vOk=1TKQ|NKJPcmdJgL z<;V&2S@b?BI}VhDs@o6MWN(+Rq%)vgH&N<7mXe2z+h*it$z1mZ7AJ|$ivAz&_701Z zRauo4{`~y@iwn_XYJF&g$xH~p;6?A`;-;gfFZFc`lOO^z3<_A7)2O`GB=)JU`(&OU zbowcQ{p!=B^MQ%BlaM)gZ2oB5d&FTX1;m% zKqJs$;`r!14c-eL6GMKhTe1!&~w&EF{ zV{O~LU)C7wH}7lK|DIqts?s89d0WeW$-LyYZ}=K-=if+3r}|N8E^{|sW?iKt4)c-Q zP!~v>{Ama-ueJrQ#OI+k!R-`oA31u9kWkE?Gng? zg2|J4ys&crUT${E8;OP3m4tnD58WSesz2MJUuw{OaCesM6PyhdHs$*|jn^RW&S#F2 ziMzGYT+NUKL;-1n_~ZZqfQYdO6{*ZH1s~kY+P9d)20{m_^$Z*5p#Xua|GHo6(h{J6 zEG}cx)b+u+6e*tX%!wFU>@2_{^{^-?pxXcdq8;C<_%w94t2E#N7kp*KB8lg$HA6Nc zt_W`J{MmQrmQVC$Es9r;sDQhl0b6K%K^MBse= z`>5RP0-U5&1m1Z(=J~c*Kzv&`Oz-#pe#g6>6-~H;shfoSvmI4phqXb?dT1&KL|)ZF z_#E+Zv3a&YF5s=P{x}~aA*JctMI*AA>(z6CS1WQQn{5#|Y(0!2dAsiNltL}v%%d** z|0NVOJbur9MJMqf!(twtThgXEeOg+_WvCasjSnV|e!Uq{XRjv~}@SocX7l9sgd*nujU7VmlsJfDyabFm$!Vry2j+ zw;k4Kvq|b^@%Zg~p7NCwNtxZR22@Ki)#r`SYg&$cgO-f`H!#OB z@v^YX?CHe+GA*Za&r;x~#Wz8nsZ$4*3)$MsE_Ryz+;{f1A*32zAYMZ`zycD006>Ut z5Td_OT~qxcSplX~+3V`n4_%U&H~uLWR~v-@L?=)|0w6*+JH%PUkTsF_u;YK^V2Ba# zL%d|LQUC%p(wGB2Pdp^iYxVZK{BA>ZnTuyLM+l<6E(Zla0Pjx4AeMw@f6E5(hPrQ1 z^3R)2y;S|&K;W-nZE%>)1lFq*?8(FV3v{*jXoOSHw2I7udU+FW)n&5Qeo)nUNVSS8 z&{qj2k~{g=)I~IcF6UaM5FiEy9b`eA{L)kUL(=Yy%nf%prbbs+eTNT|@4wYXwrVBh zHP@RDcWpoB>6cf8Rt`QgjeOm6j*RpJ9# z%Ce0a<0@i|oK{w`f@1htwTZe^d(@agv*^?iO3vv)5=_i+qnLz|!AB#R2KEWQa5$rk z928!pzA;RdqW4DXFHG56&Z%IG>^F@)Xu83&)XA~1&0}ixvAc@9L)hmz*tdGvW-#Gx zo6{=VaW5OFo3%!!YM!`MaB~qOV62`+$0B)FrCa3Ss^V}(YIN$aR~WnG?aWPJ#*{Yg zJ0PQGmcwKbSjyQs#`TD1V@jRoF_I?ZWliH)b8(nA6|Kr{bu~oeH(M&yt_?$&t?{jK z#+WEYNa%S>g@LXfEFKZKuuT3$LKCK2l(u?W>*sd6d&Kk+C?ax@&nCOD;nX zU)aU89DYg-inZ*LWWyeptZ}|s1Ln(%#+MzYFT}@b^ciB+w?*XB1XHG0%0G4%j`9#p>(+uZ`g!Oa;l}3#}sKE>(_Kch(fOa1j*A-$ zSsF`peoF+qrU~2AcUhhB&;i81flLd#7ARCg5QHJIekRxt!Qk=Um=%I^x^L%8k*r-$ z*14<^Ul%BXeq;A*$8$IU1PR|aIlJOf#2K6PZ}Jfxe~>H|+4iN)Ywwv0nJ4>Z%>JR+ zG}{`5j8HQ=tL+vvcJHCO*e+z{`Ffpx8vTTFtv#&zGa3>r!2m!Jd_QI|8_;f~`q0~G z*=G|`wzFWf;|UAfAvRP|-R_Te>4Vxa*wW zdV18{B0w9Q$7KY^IN1xH6O-DmELqj-n4bTz=nPlK^5*N;KNaG?mmgiuV>-{>$BGk!DUg6~`kZzFB0N#<2rA zW$EL0B;jWEXC~_q^StAEg*B*adwSk5sZI!j_q}fwP_;YO_Y<97s~g>CGo9mkHd&I4 z1)4Bq$+Jd^Hc^rY$s`s%9MF;n7UHh7?^9Zs#qV4?oY{A~+4G?2Ko13@=mG=);?cwA zjTd_t5ZVBc?)ERPbzBCL1nNxrqjOO4$$pnd%_y&6h!G^au{dy7ooFZtQpbEI#z$!) zUs}_UTuUTICv`u^2ZGY9%L50n9bz*`V+XwF!$$ihhpArd6(DG`PqU|gIk+NfL0dgyKXe(nTjJw0nV^y}q$ zY|Upn)zx$37~^}x&U214jyTR|iq+>C#&Ml*d)|wk>w7kA*}d*HyyrJ}Gdy;ml3Ax0 z_km0pDkjFLALtSMc>K7Fzx-O)hlU3MuxvgQ z1QN!Qhz7&$ARv}O3N%_m0RGou@H}m=ECy5%H*ej|Zo{z2qKrfCsdHNE3I>#T8{ z=XF(D)-LX`RePLJ$|4{{Lb#)Zxlp-@3;dEk&9|=kVqk=NivUgWtift`4aCCe`ZjaTMH-%I1>$oH{w~!(rT6!=D0%-oo zAYOUt{NYEqCpRP48$+}zf4 zc?gV#Ae4lGX^DJWLH_sHeGjKXLJzty5dtDS3<*%Bv2Eze~X^ZiyFJ>-dC|fPCMGFX4Tb4?XUvb&GwcdMPpEIbP{GdI|9Unu) zchM=BliQYP@fLV3*YCl&#-$^R;{+C{%F3#`yjq!wQ&lo()@~MDI>Rj4ZRsJTJ%zm6~I z0p#+F>4v{cwzPMlrb&n*rU`Q-`Q4+w?3@g9Flv;2 z_1Q&ClgEejc2h}%yv(u2Y9-MTRYg@*RLs}P{4+F+-gB>u4`7CvfqH?UND#wg@FI#1 z3aJb;4MrNit_}bm10ZBeYFt{x$tSD0Wz_@fbeKK(14$&bF#w5R0f8YPO(TCMeVlQ= zHQZMbjie?F9vV>bk_9D60+!Aq6^RWj4q1^8>*{We!0G#xk_s#02#yrsgvrQqngloz z;@ZRKrGrLvTtz||mRl;hWI!YHEVm|S%?oD&0-rGUe1_=Xc2x+^L3yKw`QX`!M~@yO zv1O!=9LBJaP<#G+KBJk&H?w6OeA&t}ym>oJGBOzH{w2y-;yaCa2%?FRK)tv3KetD7 zJg0uAz7yy#ZZLecu`H$&ddA+`xnyfA>eW$L^tXK{#nb;?vcecXU(nGTyZ+>gBv_a4 z>r^y9QFo8DQej6A@C_SU z_b7x>T;AR?k8@143kiUvP`%EV)5@u3Uv)+(x?qS6jXG6EJ#igQMSuYSga8L0TaTue zm1mquZGd6EJ6FYV(uM9H+}HtX_}2m&Ie)7f&7D65VJ z5CQ#UAOrROm-l}-=3WEj<^^K{1Ci>WoXyWC|N&Y?vM}0xVNI)t^gn;?>(o~@ z>Uq@D;h$4>4fG9I)lFcmp;1Bvd^7Y#C#>wBPm^!a3 z7L=?*A1^pD=OG@W&pbwnIZanR(|!k1Pc!q6P@zz`r9J#~nU4|gPuG3_;dxE?yyZQ+ zBpxo~spJ?NHZil-M0ekK3&osImT*swGzD=YxS3OD*phQic0q{5IMl@G(KS}0J#v?6h+__MV=1 zSlrH6Q@x7GTkCy<7*iSUJ$eBmxYVSQmFkFIW+ZfINN5UyEI}y}F2xh3B&ZVr^gb-tMj40Vel{90s7H}@F0X)H#bNj}L-Pm0`2C+Y zd~ss|l0bigV9d{&_KGN@i%AKpw#a8aZ)b=;EDW&+D>_6P>Z6CTIO+?F1|^q_QSfp!u<6PE7c}SYB`tCA0B?j)kVp{~2_%da1PjkW%V$5e$#Ot|D(d`Y zG#viXrMot=mpsKjr`|NV8aZw6l4p3#=*c<11Wau&wL?J#^jkgsA#WR`wl~Obc789# zFN#_wANGt&K*J0W-5*?@-1^DDSsOJb3^Gzw=sID70LpO_o2sjIE;9_o#%CC|MqJ{r z5rY>oiJSouHAN&q+ilW{moqaEvp92{!*EN(W)7PG@wHasZj{3_Bd*32hyXwoZOaPE zM!gk=!_h^#5@RQU8j1iBfzJ@c5ivNb2R_!~@3qw|lnTm`pkjz_)~tWLR%_4nIb{AW zxb`;WQ{9DY_{Yzes_lv(?~NGaMzZv4iN=j%V~r7snb>KA6pTL1`_Uu_7PLYCy!kO? zz4^h`ppr)0unH-|hZiKNh#O8K8)HcgHrto;BcE+x zX=?pumiN%%J#szFC7v=X3g`jUB(qn+fDgO?{!ZRVGL(G}CDU<@`9;f}5WQ3iI-u#{ z;YU(KqOr!x)&|iBls0!#>DMxYiH*EpY87ZjU>7r|j5^-XK+r;lb@AKjjrHf#u8KRa zvJTwO<@tNm_x-m&3#Z0AgaRporPBqDQ2P>Md#4%i;Cwf?-20GlbH{%|Xavyv;UI>F zH5uvZ%e`_eoOKq~Yc`-=x^7Fs0FJM4MzNAQprmp+WJhJimeL3qt8vxONhgd%LMJ;h zb{BE;KEC+=_peO!OU9r{?y)gQs{S{=YFy2THNhydhPsr-*C!iK)DW4 zh8%!l<@TyF-w%Xr7jVULh3b!|4@3CBf%3^AA7Sf%echf!Ze@1mMUU5=rw)wVWX;OR z#M>OP(cb;4{J*>Tu43dP+V;`pVAq$a49!ox4vb``IVIB_!3~2O1p3`U4b6GPk%gY# zhl*$W@-XxJxDpOI_fE2eeNI?@+ zx|FAmg%RK64_i?rcmuf?la&G`1Le~%^!8ITib@hFdR^!{on5_1_04Zl^BYdHf3org0HAWjtrnPg(Ao%Pu2@wV<%q|K z&kVc!0 zuIeZd&@_V&crI|-CTmqPWN4znYZQXgo!D-~u8-S*PX%&q z+ZPLD4{iup-x44ZygrpWTijwj*u-L>Gze)Wz<@@eMz}dvOH61GJ|nD!$+ zbd;|yqo6PI6I~7CU*G~E9f_K54A%#|dNMoJd%HH1>*>^~ ziZ1(+oK!T`S>7=gHMfz0e~CF1lSkar7VUa7blsum{SwWpx855;1Vg#&SoEv|T$c)V zMXhYUJ-*o@g4-eg^|wX+a5d`sygo${0vWsC7GVhL@%Pcc;nv1X*}fdTa^EgXH-d=O zUR!$TQVU-psHG5T)7HCC^O=M(>;MuCwsF0av3a-m2z*HSUbV^&S!0(acV9Ct$wt-! zB1rn*xB1`TeQ((k{{QY9i>uRfNBC70ji5T zW9mNhSY^kb$3^H~9THc-fz{3e%?}V@27m^0M$c-oD>Y6Qv88HdOsQzablUHii^%m? zU*GB3YPluzUoQ>rHubhasG6@jGl?Xq@#1_Uuld?%pnOf>|F0frT4&z$=>s5byO(gw zkNm2MZH=<+U^;aY5(POY=}0w#&g>LbBjbQDw0FVAAsK=aDTMqjAA-rj#`1D_^aSW2 z;ePj=Ml>2U3@1T6H^+*jmz8rSxHHXn=w@+Cq$uV^Jm?hioZ~9R{L9O4T%4|)LStDi zjjn9#!ujHZs~N^QVCg*0yy+0>gpABUC(sD({SMr?(Ynt}9L=7OTN%@MG?s({8Bpd$ zzAC8b({TE}fY4|YTW;}?yfb@AB#QU{71FjXbfKF$*w-Oq8YG1er%reBFp&#fy3wc@ zj(l&NjMe2mn0uW+hyj2K4B$+|39J&-9)z4cJn=(0X73Ru3S5#r)Nn4ZKx|y@?ggrQ z+NEWFANid!Gu%OIoAL|G{*4#d>oJs2w#HCFIS%NH`Vod=6aDrsj z@SE{jusv2Ck9`vi!6MtMq9qQtj z?$dWl)=nFg9EksF5+qi0p+V-L;XGDLDPp3yQy*x<#IFb=G+Oo?=gues0B^VGmcKIV zji?9!1%*_-@Zk{v7x{CB&vEWO*StQj*-N;Oo_7UD%}d3&Su<{-y2av*L?Eodt;D*N zhY$iKi6OB#g_e>~!Wm{ZZK|5DmL*z(sJZ;5&ZoTWJkA4m@_jBC=Dc5H zV*Qv-x5&A=`SThNi=5;8!bo8lb}9H6l|^-RN+{NBOBE|*mEW+yoFriMPtf`A(E9(& zz?S-Slb0UPq3rQm%Ng}EyeM?@8{Ic`4jJ$Fqwb@^&AI3HT-t&L+nKin7bMGZ5fKa< zh=@uROme>z^ebG#6z>ButRB$@Ysy~;Idbj^=V?b`c@O3ZKF^mol(2G-06V5A?xB&% zqgfniNREP)(O|WPxXXzQOuy}MTjEAec*_MWH_D_yv?~%nJe0X43`g?c&&apoxr3)} zuroklKs&MsGk^C2WCnRK8RF!DkWBeD_O+Z?<)9Spun9jZy}yXcdTE%xby!$Ua)7lkW{ zk1r~oPXnZ~XO}+cj^8Q1wZS~u9IhC-LCdU+p8o6YSvVnx2;4oGl_wd1JPqC#sM(}u z3xyl+G*=;$JuDbAfZ4Q{PO#lo!c=+pFbf93)HX9B> zp;0XswlQ|MHImI_x1mDma9*0N|J(69q)6dBjQ6kp+1{)539D1nmis|r-Y%+|S6NtX zQ97n^Wo>lBhv*Ru-A~_fg@(l%cYp2WB!prVywko^-vRq;WAn$V-JR9%g>j~Z&rask ze(jaK=TF5Z6aPhq=3UH05P^We zU@#a6h!6l60Z%<>wDprxbQH?0hnEM~7>YD?E8dM>sOrzYhg#cxKKJk+7HjLk<_;@_ z-1u5XM`s7Yg7tfdN9U3M%M35y_#h=Oi}pr;W~@fCM&hFr|6)RSK`_BDM2 z`$)OS(KwIuoc>Voun_q|-sUukXR{gb{Xfn6KjZpfAF2CoexU2uy;nf*xxrW5$MAD|yoF|C_Pk~Ixh`Md8R7^a zOk`rB2rfx(TZo7oAYep9L_#9ge0fII;$Aa=VbaI(;xYypaLY*7UP$>qYJ^5LDg&^; zuT-ijoq8x&8tY)ek(Eb2HZ3F;Hn}DT2_3=J@MQNY|u$9aHRL$C4U62 zoC*H&&wIXD$pRVOi0ePVx~|0Zo;z_|^c?68YE-o0_!06{5kbq9989nplc~!ssT8ow z7$KpEXsU>Zo_rKpc=z6Cw^!W&#b@$rv z=hVv=i>4*%Q^h-w;I!LfFk>M=iJ9v8*dXyWoV18Z2E6e-={1H!bQVN%7bLM`%U``> zEEkZa`(06dJ>w(OM~qq*00(znc)WeYwcK`k2WKcW7=zxfY^`IGYB zbMPz;{v50p_DTuLiOKncMw>A8OJ^n2)ld(PGq?3WckkDzUkrXkds+lq!X)y`w#PK% zsqRO@m`KyL8*13}%+&OkjDN7BnLe6zzh43Z9uCe{Q6$|njTig~6|{yH!|*WPx)w12bv=hkA%?IV6h5vF)OG} zL_Yr^#q@Z#+m|)W=lJ1xD>u2u7j(T--Tva`D4!DX*1WsM+$>dZHJnC~W8d0qN0P}8 zzJ6`4Czr;VWDqcHaWuyc;FjgL4j>2`P=rK8MVp%>E`)o59D#Nho}uLBoQ>nvt>m6<*Z74qOS{HL2aiQ~HY zh^Q0&%yG`NE`%&|7t!FL<)}G|aaG`sY0TECZ+BYum%G4k7MCW;`o2GVD_=$PK-&Eu zQ^n0!@Ef07uE+ssu3YN|rh`6uS+SwfQkOQfXB!)@$5xCPi)07l-U8N&VW0Eucc~OB z9#wb`>dek3TJ!zU@=Fk@yX8zi4+`bF`{2IAWx7{w^kXCW%)E>9ZXmSx2HiF|3Czn| zv}o;o3P)>;cLuSlR5{|p*!Gncdes?Y--ZL-W=x1HCRqG|hzNxAc7%wC1Mw0$a7g9; z|9IKAFMGxaM+E0_pV!pid~R!N`L+*x#Pa=*BYWEYzb{Urd-+3rHpA~5`Jy=TX7`fu zU*M|6w5~*gP`9|`|F>L{0U!GM;6S`z=l_q*cKcr!cZJt?i@O>WM|m~1h$58gbw49k zP;B`u9Hs!-z_sM&Mq}V-5Hb%e4q%udh+Jng1ZGd{f&!tY+ZZ9X`>2z|0nF}4zK;~! z=rH8_`|h0-Cy=-what9SQ)?Q7QT6%p_y;0G3nz$h;AxMq@htP`%OkLB{GUZ6gt;8` z-MW1a;YYja&dS(Qj{`BFehj>k0^{`DXSeb(isjT>KHc=#p}xA;Q!XUu>7BHwGvuCP z;27#=158P1F`d5+!uj}Ry^~q0Ja%)6rM<|{tBJ#I3Y%%p z948RJ6|h#ttnvYa-c>70{L^+R7dqlaBx6K(wMS2Xqolag!$;9P{3sA}^7)@I1PBkD zI)`S3yoihb%qzT8)JIENfJ8d!aSx9;XAix4?q>OerEyc-QTxiqu6G+{FxM??zX+Xk zp7?eMi;mav=zt(Zf7dD8J~NeQid->5f6(_J0szo_6vRYCX?|{00IO; zfy|RN6S~B{1sVAf5F&uH;8QB4o+HA4H}F}rSG?t8h#-i70p4F-9iR2V<|+Jqwe3vi z+De^ph=@;&!%vY#Sp)=8K`KU3f;UnOS~dgMH~;`590c_~&T2@*cDp64N|^<7Dd-^hjdnL4)}FUQ7!V{Ds)uXifQe87dOy_<`b^lEOQhJHR{&3StIiC4(cmb>#=DvCX4k0ojRgVY!9D4g8@G<#~GEzhE1u= zrHKP4>63p}iH>g1kcBz`Crg-sL=>o>tgwg?5P{-2^*P`W1Fs{wjL$GT<1K#r^}v85 zr!eIuY3MZjENwslY`|U)BJgs7QJYNL0RD~*Qn^_}noA~O``hx4VR)(aLNm;RvJ2r{ z<_pr@)~L5zt``eMhpN3St^$DOA_9pS;4z(u8NkLcI1jr!kEb1wyHvFuUhMIJoUH3&3L_d2xK@7b2*C-de(zBAQ^yB_wXBEJ@93fUZT#| z&3QgQ_363IoX8tH5EUFA|5z&=m6p0}shO~NKDGx1ZY(IHX@pz8J!Ka!x5-_FsdTm~ zEtce>g-xb|`_>q5)v32dkzo`liV7~D1waB(und9+0U!hrG_OA7EL+5aNr#4FJOoq_ z|1;akXFh(T(+jF^H9ZU0f4KiEzkXfXftR@p^P?4>rw8*ZVhGrgL1 zz67+7@yE!}-!h&i7S6(T+jhsO&7A+oI)zb)Dy27jTxGaa{M4$(WTvH>qhf(l!w(+H znumI8zedB#_qc4sMFI}iXc1Kt1j+#}KmaB(AP@rDMvI><&eCSHnPpi%pZEtx_QgmD zz6z4guUZuSFOh~IE7Q&Y39Fl5@Z{*@Koo*x5f}>uEDO*ifD~B3_G23t_y^1oP{ot= zrVqU^U?BR!Nhrv<8Z>OtgChoHSu$jxqb7|SGHj9rWMvdlWfWw9SR{f5Kr#%LV50*J z1_dJ^Vp3#Z___uafF*$MJHD7v)6-W+vW&;$sy3BU%O!d3KO0C1$Z z;FWE+K4X*C)A`RDGu?KtrMq%wh~v!T!f7-z%#}}XQ0I9ynVK0P=`=({MRU8HG&Tso zd5(n_1KoD!_p|DH3(kox5U2X#AQ2G-Dr!8z<3Hndtk|LLI2%(s6bwXpJFhNW`yGtL zx)J~G3w8NdS>|wfEx$w0)gQ&uivi&+D((WQo#e%!m1Um*i${FT_47&QweJk_Df+)< ziMGw>bKl|Q-Y2)kCadO@s$c#xUF&`RsR8zN2Fp#yY5&UFdB{9BYwiq-l)RI4x%N|6 zT2teqm-aCR31t1-eTI*R&7kBiiaw%pJf$XRh=BqXCxCzi0Y?x?`@F*>;2mkGM!a<9 zJfYMUR*Hu>p==fsNswUxK}VcGpf)1Jz<^-M6C}Vv0|dYc5kL|K1W1T13uIJTN3965 zRFQC?Q*A62m8qU92J?7){(?jZTF^0Yr5)&XfQW!CcRELCga8*F^&r^?GnxMU*hq*N zmMMo~N|>QtQi|!ee7~y6E2vS3qRV0KdJ5xU{5fYPPr zMwmEMz2#d->JW`#QDN$Y?%Ypn)nm|a{_GKb^WRt?K#7dsNAdK0qF8W6+@E<=ZU)uo!1Wyx(%3Au)P7KgXx}sX+bRt{cVq~Y`KGZTP z2)|qtP1Xmwt-pVhhR5vaOxaL0@tf2Wx@YKI#GL<VazhKKPHn%0aL{{f|jiRil zr!%$DX3fC*ptT&SpRb3U?~y!%`RPI0Mw})ldH#R#Pkn>=&!5b5pc%V2J^DEL{1eGb zjZ(cF!(*dZRBQ10AiJJzv+wA+iiAhiBqvlPGO5;p{=G>V=kGOJd3X!D zb?lsMUThi%vyx)W*X@=)&wVYi$wP6%=n}=%b7Q*UmJc5Nsa{SJ=a`<~G45N%3Qy=Y zQ5x5WG4a(v)&L(YST-C4$|^e4BU!U4p1?E7dAEw&BdrLM`$#FhrYaw)6)sg{%hUG4 zF*%9Nj;E$aM+@?N3M{ zefy^y`ZK(UR_c3W{gxXi0lU|&>o{gv*#FuT82z2hrB|cTb8c^ZK#O(7s_FPwIxBMd z-g>Xgx)>}6)Cb4gQ7v`CmvGrp$I7!dVFD}JFhBwTsudMEutwc%H`)GILFQHt zlTzU?|C+1!F&tQr0lV6#YBb-=eGuPkZrsZBPB%!onavZ#w3!&@RrO8n{F$7HUQV&*}}VlGHsP&-JBI zcSheTA*QbIExFOkXHhHvfa~=WKAcVgpn`Bkb`tq<~=_VG&k}gR#IwH8_HCFc54P z-1pV1*i^~DRQeQCPeDy20TJdpa)_a+=O{@X(gh+!KNkz)R<2Yl6G%?tIJhzgWax5AW>u`Go-o#=oMLQc`F&Cup6I z`aBv=%rKq4V3) zg(#e)v2ik+-L^i{wB<9JhRZ5jn9a@2SF7VC-+g?sJ)^MUUiU3hKn2J7XAr^7u~1$M^_HC% zfhrSDfBN85kpcy)&LF0i>QeF*F*3b?_+wfk*#5t(OPGBv24~@AgRjHZnPuLsf;cGN z0%HTGtBR%gGP!=E%kR$u#{h_l>$Rx_02BpC{Au=g62XO$1POVmMrViUMO%24Pg+U| zH6#b~{-MvHngy!Mk zUVwfw5Cg$I&fgtAh14Dd zDtwD~HNSHY#863E<(?KU8!a>PFG~M+g$C{aQrmKz{1p206KH32nrXt%I7pSKG6Wwy zkVWhDh>f4O`EtHCmIouVa5E{7$7_-p_D0x`I)^7DdNMVX5Lakl^nDldv~G8OXRV}4 zV(kt5ldWqm~IHp+u0AfK4DjRa*Kk`TbL?gVZCaLf^HovpV za6|#M{oX2=1okyD9nW$DR9m1uo1a!5A4Q+?syXHEH%6fK&aY~mt69}=qgX3aay01q zyQTaDQ}r?c5e=iC(F?=2cPBOCspxZgmjpnFzvaI^{R>N$y?UJrxkll3n`{-|c+Ked zG`@EqY@RRlLk>J#Rk|7~U3RO95b@G2+qJVyS&-Q@zjPbgWE`0fAN1w5f22` z4%R>r>wsV(=8^;y8M~B;3QL~r!;vuZe2q-1HeMk{4uj&?%ZEd~!n{Wrpk`m;+OBhY zdkX*vANFcHtQwBR-~a_+h08Wec#b7PgJcXK0T5y#ObB400dM{~u80UDodm>Wh4ZJU zT~WDrl*Rj_+9<2hX}4?jon^!p9tD)ss6cUtp3}vA7dnZ*UtjffiwXoXRaDanl_Z=% z2AV=A$7yFi?-a4n+cO{5d^SW{t5H$yl7vd>?qGHBa&_%2a1{OKU^P*zJ@PK=CpTb7O;jQiIfRS&cTU;HsPx5T7PqpC*Znfe3wdM2(XNqiVy4w?NP| z*K^psnW`?24niR3H~<1a`-Y2x6R-dRPWfA*r0?MPY-{Van!gm3%?!#H(;N7X&M6b@Vd*yep`@?z9r3Y5c$rjEH@|g&SJ;Elg_6q(5 zENIcEYGc-cjQN~^x5kW)f1Bt%F!uhg_S<>vRt(BUab+a;SyrjEr%vZfQuN)()i=qqWmbBDb5+A_c@mMMX}8 zVicaj4`Jr_vH~H#^w-z4$;4+giQsB|HEH2H^ccrSyqAYu&CWgUIj@Vm8x23&$t9N` z%{6UO#Zm9k;~@Ea(U$v`kNIz=nr7V>huV|Ln{Of$F{W3gbpJke6#0F`5alF+vhgbdArwN zrLBH7z8Z-TwJ-n(4O;0D0tSc?0Yk;4HNx(h8A>1m@E9T?9Y~o|RTsp*dRtvtvg?ol zh$c_BS1S4d0CI7y81}Bj8T(HFvp`J0kNDOIh=@y2fD_xb<~5HRe;wkKRl4+&+c@)M z=H{!XAmu-{Qb<31$Hm9-?|I;F=#**D5q|8|fbROgrCk$kqO;wlucF@tHsL({0Rkg{ zfFMN^UX1G*z@lKRLa97vrESeZ0JfHt5$Q zF`DpBeRI0;L44;%nRCz6d))o%2tV1rBhL$e9Q4^cN`HexHKTK1Sn{&Pt1fMjPIv5| zd$-Cu6l_N2!SCB79Zf^w^)^cpOVxN3!76o|x<#`J7wjb# zAH;@!Z%n3HVemh&)08(7M+;sDliTSROQP`*D3&nB!~b_N!O?ctP+hmR=`q~sRR8U$ za*A`g)Q8`Ab`b+eY&Z<3BORtwh`6K@JHN*zVCDRLpF;$VXT8D$t-0j;zYA-R*cA8Q zVFqA98KdMF2paA6pCo8VqiG>GGFe9+eo=`N*-TepW#PGS*{02Y zQXt9xaYwb{VeZ<;-l4?y-Ba{CDv{HU0D%Ci$a(WT&+c-LB511qNB=#GjqddpX+kn5 zH+N>^4la|p$KTJAgHo_iZsX<;aLkyw-GaRnQD)Xy^KoKrI za5Yp~B)rDIc#D&Mz^K&Ga_8--qHJ^Y*CDF6W|ScRiu3;;{q%&b7+KmAm5D3kyObrI zNAFVHado;&kOh}BzNf2@WS7nFqJ6i`sj<+nf&5puL~~5Klaw z|Fh&$nz|y3e~{ZEFT3Q5xL~*X5)uwTD^JpY4R5ck0D%E141Vd>=2I(SW)j4Ag(Ga_ z)ySYnXRL37hQCN7`Y27PTyuOsmvb9SWQTW)X3UnA_upI77`Yzz@Y^#Z+GourzIvy% ztIaFX-H6Sm_M1A0=3<&XN?+0uZOwoY0?N?WJUtNrS3nRb!kHjM00h)T0B1ik2oOSG zA|fiOm2;aUx72v8MYTV#R}BY9_LqGU>Uz&hS;Tt1SpfreMJCXJ$@kyQ{1YQG3SRM^>t*`)v zLz(8Db9=9N$rNCS5Rp;JncwqMlF#6IYPB25rJdy@o4FASr)A<63P;9>i>LtrNeg~Q z{VKe3Rm}r=?_>~U@1g)efbU>q07C~ACvkJHlU-qA&2(`q4|^GP_!NPcU`|PNq+Dk@ z(FEQ?Ce-{8E&#ez;oM2V$=03pIMdnmEN^?Jnq16^%B*2bjpN@Kpx-FEfdor&az6zW_gEq4j=$Pvkbgi?F!;w?)8afm5;G7&D&QQ=kA>a83OzG;v+I&Bj;M-9A|hm zj!*`^$LdqI^*-(56B4f|ilznQ;n%N$Sp-5u38T#DWdi@(d4hP((^2ow^g2D*m#3GH z4u2lv=n`RoToCcxDDnA$&s&C!u%>J0p>S@^+Q*0wgZh(+tn`93&f7B7USA)XB8?ZH zSo;n3v+6J-cHR>C(UJS@qK0^I0|o^v>Dp}@g=_jN#VN!5B!31u>ZB{8U!H5Lp@OTf zQ^r|j#OS4J-quDz$+qbJ29Cj2PN`wM$7=DlU3-3!HoU~KC+g(!rCCk-7@>!7bTyPv z@s)a!uzd_|SC^z86txa}5f7KHV~uNa{U$p{7U0&WU4pnqyZHbDBEDmyDUv3HYBl5x zwUjj9XU6{DP%3x6!Gx76jy+xzrA%^l0$%?&bs z*O6Gx)N?y9_G9WTA3-WJc}qUhuUWAmKnaOJK!{dW9UuTAcuKs}=G7zb<)>H9_IP~v zuO#MeLp#51YO?Md#Ai3bmb$rn&m5+L%0+yl<9Z!leK{Ncc6w7x)~@2v*E3V1Gfn9; zFR)y_LV;41Zh`c4{oRSR{4y0{N^K=e5#IHNpb+rxHb4;q-Z(?h07Ck|Nq-k}vXyVi z^Z#Au4CgXGr~3L8HZfdR4ha~E`aF2_r6FZ8Z5`!zD^_VXJHvD~UtbQ`7l4su&b)BocFL>1nTr`0}# zJNJX4{X2bfY$@H9A02F}-`=;^)ikD*H(DcRVl|s~b1h1pEx+pQ^*WOVwhPF|t)wC> zJB*JsPInP&X$-BL;_^T7IQ-SJ_>?@af_F6o<5J(mBEs9?D}Y4*uEZgatAN@X^4Ocn z)|z92xe zzGlLTYgVy5s3d&0*zS~ek?HJ<)tKzX8@erWKCfcyT$`7{+5> zT$mP)yGp({1c=U_DX3x2FN5P6Gujyw=;{1fDh(6r-OC?4RJU?7wGsv1$4E$mTaCY z_fYvhzN7v9ML|2@ZiI5I!#mn=)vA5rc8m4uFew*z8I~_<)k{M9X;C7%sSvlh>cVf| z^mEixKC_6h8G}CQd{S}^6=+luXWRxF77_(MZ|c$M|UFa7n{_Q5=lBt=Ll@! zh8SQl!wfSWiPdf<8T;3pr{)BWtbzX>CznUrVsCR6{H#=M<-nN#DM%1rJ$%WUAqYT( zAqZ-JP8XfruvlEivm}I)PV9Ca7AOqTZ?r6oZdc?)8>%;4p>z50@r(Y=w@_KX$~_hi9Rfg|E(c z4LT|0(!LPxpaK&QmuL9upN>PnL;tSyo0dJymDa7BVT|KU%C1lLD>~}@z1*(vS0L|p zf5cqeBC{aavNFQQBQaqg?PlM0ws0gwL!{;Wn44;YK=5}gZo-lG%k_V6DM03zN6*8r z`pjU6iFvKU$?7poS6n6SZL{Mi(IWK1WQNz`$eob@1dk?FA*c(Vc1GNK|8!U?Zm`K; z&7MCYapzKj8?STYGaALL%pcntQsbP)sFbwc$^Zx6nNfrSAOST|XViGy&9jN<#Jx;Q z$%+6F_*!&nCfztY2M?>mB$bLjXWi4`B~h}`|IGOq2J`QRwg)&lwApSG$HtBkeE?Wv z>ry-3b@ZkOT&;|Lv0d)@t;!XWKF<$0BlPL0+pe->_2WCDKbtpcxS7sn-XS5X>MfqJ zd@BSj>JQbx09!hzno)@7V!TxL7wIXTeFXaJGFIx@ZVArI({dNB`oroDP}{gjFrUOs z9hK7cA(jg|sq_wxu;{nY=`c(upFt2HH2+8_9Aql%hXOS*&z<-c2M+Yj^F zynJrRoRADxqf*n&m7q!>4uScey+;PzCR#Uw{uMMgV zb8$?;UATW-#YvKars>N|PQ2YTN;x&kf9Mzf>`ErP16TIZ!TU~Q;alwi0uL{n3NBzD z+&Mj{dZEU#}A-m>KWY zQbu!%+q>*mU&PTPsKHhu{E8mRaCcoVV!R2>?;m^MAkS}K(ZxoRS)Tv_g$r*1Aj+5s zff&8l=^WxdbBW*g6Azixa67UI5_)&JCq9pXgn$G?N~`DQJ@Jukyqxc6j%(q@n1tk| zzCu;{;6uX*L*cLPF{0WEt#xrVsd#H02C~50Bo@8S*)cY)9#)Uc$-6pHR zeS5X4g#e|KxYkDL8(XWAQrx#${V%J9qhVP0{SI6n; z^Xm(<3`e9Q004L{-4CKwq17+csi)t+Jk7JC7P3=gnzH3R?=|WC*V<*kKBf(hr{3!` zfY(3vKOL_Zl;iDhrj^F9!~O2g0haEn(*bFMR>L8Z@Bh4HT^o|Ym`wtMDtUWDq;ZTu zCXe@dtk!EUX!fW_M9IKp9pD@n_ZE9$aq;H1Y>WMm)vBhM|cj_P9 zG44j{;IRjJqL>f?P6**|d#}US^8QxVW4zA$rco`k+rLNK?R{6aUpI~3*#Uqi#Q@cUvN_Ae^v*4!mn&qL#1g|%r%6^pq~4b<|qW{-G3;)drR zV=tzHTF^74)AIRt-?2gfAhh)snTs7$+kl;3S>HZ;^INwIy&3J(TjFCYZM|z#tMWWl z)~|MvdjHSRL}^*jBU0lpb>7U^!}adfIc`@`dhJLu%o*l)z&9(@^L3%t!DR{P*L(m5 z_p=q8MOH9*X`mq6-I!Z(jP_Cgt6-I8H-Cx0huNHgZkU`jc{WJr4q|_Woa<5)vDusJ z!$MHFqM!lTwodzJs87xGn(hGW8n6CUy30wET#U~q_R?lP8fI$d_AVlnRB9xv!ooNG zc8AJL_O-@Y-*f|R6++~x=N7N!x!QLno<36yHM8J>1|k3eL_S17hqZeZ2Ia?X3gp9I zN_u(pG{a5iOd7ZB1J6b0 zeoOn2^M;2@PHZ4R4lP$(hHw_&tLAy&f&@JP04qEfOCnt#i|lPqv$mMzKKNJhv@I z9LDF?oiQtm^?ZYjrp z;cRQvGyI8hV(gX;wT;mO=-+eKj_!LV?7kGQNZvjh0HCK8wb2eI>(G^drJl-mpN6XjBw)Pg=k0GG=V_88iMm zaMCZr5l9gaj|r)%e^vk@Aw>+B2!V4RVo|Ld{ojMBK_xFuvaUy?QRn?{@wuv$01yC( zKJ;QctC>KExMqM1+y>rn>5I8t)EQTHTL+nhRt1<&HZk5KS*ZJyUms5_e^`%{B6Uf5 ztxhX7hC`jaa`zQ^`F-=2m*(11-X}9N7s`2`v*+9`8(uTKr2V?*@763eK#<>{n8{Hw~Z->yLnTnZ&6pDdp|^OYG9_j z@o%nJ)s!~35?f{(1#ddkGGXP-`n9=|e#IBzC9S2PdVzTIy{)Fw@i7y6*m|nfnTr*} zM>P46L_mlUbVU#%17Xy@e(0`@Fs-Bw3s6wiOQb-H9TtJ&J3`y$Sew9uasu2X0yW!b>d{N z;rgnb{n_3_HVyAT2(d2B^1CGxb5sLR2r)@Fn_5ncu=D^)j8qJclPDk{^nKUQzfU`E z{73M+1YNSdNmX|Wz6XgqAKFHk|G440w&Vo?@4cH}I$ts{5GxvW(_nv$5=IKHd7vaZ zMu8(HzVI`gh@8*;dopE3C5?3K>?&`%d66PYX>G0~fKMU|V8}8?Oqciq6(%T;ejDo$Zdh-z_PawzUYiLR3&LnPr8 zOt+#K{LLhH>bK2m+TkI-|64g;{ORBeL_)iR{`qHPN~F^HyPw4Vp+a<3B|jB`vpo{7S;%IQAuamQ z>K$aFKp+V!1c3lt8WUU?vPpXc2rKOQzmv?vXS;1Ze3u4IQgeI2o2qYV73>Ox^lk0i z%(&dIIh)V(I(U^;;yRG1br0Vam(1`QmU_y*M2rpzd)iG&7NUGt1xWTT{34^EAXIYf z+Xkl7{C%BXv;mw~#`*Qig!(?_r)-*hcR#z{gHQrQAIWL<>PE^ZxZekCVBkN1bpWL+|w=bC)6nCh3Ni zpabFW?)K)-QUP1gPd_P|zG^kRhyMS6@6+}$m$p=?B!~0J+Fq|eR~e_YAmt(giF5XQ zRPD9kLyFOPJM6yu-|x$?TA`x-n^~tL^QgvxD1zzFB>*n|-M9qJu*g$#!Rgxh0_&>Yyr%!w9}5t^pKQ`l$#2T$2VSF`YyL8Yv5DAlb8+K+squ zJIo-u6855v(e!+x_8^&`;bw9;84p>&Ym zaPXb1bbtg1i>i+E;}*3qkf-nGJ&>I%LkOqP zFvsc-v!Di;*oTNb9PxUB0`hxElc2Sfs06+*tpCbx%Q2QL}&E5Z{yZ&8o3z- zznG>b}&Q{Z8@Xa#$N%P8JbJ;U~)Vv?={Zm6f!=Z8F%uYjRz4=|&+w*w6 zTmQfH_nWuZ^c%0glFtMqE{PGlXi_|~00@NEZAcLiBB;kP3Ef!HBv$MPx_fsoU)uj9 zqj$9o{djKP?`$C$X#bGh&~x#d^-XEbZy&@q4r)SgB$M1e`CYANo^-q_FFe&|=FZQA!0t5#5)o27mo1D=9 zU8?Kn{2i9?-?LqD4(i+5{G1;%>~~!%Beiq%l7~Wyq-=#-fk;R6j17BQ)-(MkADg-l z6&%H@efLTLY=UssHZ35v;cIc0v`Kr2x3otnFXF;W{RMd&d`18{IT=#(Pl^&kHJF_R zomydOB9iwa{$7J<3M8#I?uN{+ST)%{$F#ne@+cbLrEF0?-~|04@M~!RfGG5=;)3QP z2iMx;1GPPxtCaNi-IcTSJw5m!QixNUozJHg&Yr#@dQbJ2>ALG?BA8ZT_;m7ay=eVM z1N|3;r+?S!m}5Q6PS^e8^_{1}h1s41`U-!&B_9#L>3wMb$hzAbP3lWeIbg<$y`#n? z=f_!^oNW47+DpMRJ;3?ophVGu>u;{6g;wwG_~YBYhzL199KjA6K-pmUg%vLWEl=8kFX4WIq=M?+ch z?WavnS6JIKH_Npz-Zmx!aG`?~MG6WMiK*T;KJVta%xpqBi?)QrvMy5=q&|894T8a-mqJw);L9bRa zpk_S|$ALUO#^<%eU{z7RUIPjmACK459)LnS>0Mxn$-dP{qiE09vYyV;`)%`@#D1qK z^6eNA5fJw_CIAQ}gYz_bHjw>{{bQuvjXmWwR^t^*@yYSerJg0bdvuX+{(jYG;K}a# zH*d4lMsVmzh(llSZ1*iFS1KLq^yNqi$hij~eVPCfBAkvP65Y={rbYT6uG4=r?{n!- z{mtnQEFz)6rTkV-4kSk%Aw;HbMi(Q`W_p`-!ce2n)BW5;UXgNL#psTGCk~3zBqa_q z^`3BCEiH3ac*{3-wz?HpRu*#1Jn7`Y{pvvMiOrQmB)O(A#IAc7a*?9c2 zj;QDoCGr4`ByvXns3nyGn#Z@swo@!63#1w*1PhgKYTi|9LfJ7QsBG8-$@-{=<@)=s zYhBCx9!{y-^*x4G$B(YudkgDJ=6NzanfWT7tB>B#)8uOahyY!$!Y{*k3PDEKGT5Ko zs&-j1Suq6~e&ogDv)T&2UY{dqL_|P?&QKK{d@jDyYPZIEGE_6G)$3avD`jv>hiDoi zA_IrDrP<~(WX*aodzYRjzE|cH1FI=61VD-ol46m;DmOxF(9m?*^s(Sma_JsRg-m8e z23CRTeB$qoQW5(+>xLN4yT1oneHr*oro*G1;r6zVoER%4(WF3!58nSCl?f&%@sZq_ zRGN-Hs~a9COReJ;m@H9#8YX=0wOI3h{vSCKJOu`Tf;CQ2VnApO@sY*exmPR7O2?rpG@iB z${M!z%OQY8uSU_lsFc?O zU5C{)dz<$1{`os^x(sIJp(^&^IFS)z-BCK(PN%%7{^^8Wg9+IwfEIt;H`(o6(~U%6 zR^`n?^_Rj_C4un~5p{jGU$$Q_k7d@#jzHrs?btOBvH9^67ZI;lw)U;SpAJ|6fdvlN z8~{1AZIZf871{F!ar}AEGk`x<=0t64~ zSbLf775_7v(8sUZ`WVFrgvbO6xil^s3JG}jZ|aRm0`@!2&&G(WlQy5Z>*`rMjlI{g zeYS%0YHV~ou0ox=OLM^fzI&hi+TLEjkEQXX8(4_rAss!}_Xpoew*|E7|1=Fpf5)bq zoO*N2EwGwb9oy;f5ZzC8vnN#;c)~qsdIAC+r4kQ0h2Ss*NRFXKbsoRyY-WE3sVP0i z$m7}O+_}(f>8?{BxY<-#ihfsXrLEd9Ip`PO5#V8bwX@ciSksy&(|-g(QayBMtrZrR zH6(|iWHEt`8X4jm81ytWG&D3Z?7Taf-fCEiisf>sRVtN2sZ`sculpG6Fv6(+pS- zu~l-4D2~)ABRbFq+R-sdPd}d2>k`!O@D#kb{=MGmr&jy0T>wsWqx`1@IfO&c(34DX znCFWUsH4KbM;s>dE9Mi1XJHqC^+ssrB0n0VI!L_#KI9+w!k$9Q4u2e;WwZW5S z)CR20ETd1au8``}|C~2SDQPS-9$-X72Mqhq2mm+|KZX8$t&h%Fhynqf`V80vL>2a` zDFM!=4~ZpIaO2ukIv_d3@0N8b|DEsV=rfixNJ>|}^Lb~K@s<2bM$GlB3rX`UD>RVM z^|Cu&HlL&?muJMpOwSn1Z~DZifUBCUbZY)RB?!>q^>_>8l;QsM&-=>b@|~h|*_6n3 zY%r`0o-W0U-0?m)v-J5tdn;@3@i_y?y4Lji9?N9@AGt*QqUA)2crUC(gkcg857c;J z1t3BMAVhxf9B96Xwo?M;Zq$6c^^?sqtOO~+8VXvWeQMP1Yd`Q7WnAj-qotk-> z2tRj9stE7)>r-8rJa!__dgS9`vz_+TdfHz5uXeEvzP9&6GI-R=9o#9{zieL1Jt~R@ zChhTwv<(M?aae45c8VUAi zWDfBF2oV8Z{~`zvT^5zU#N_DVyVfeJX><}mz(fo9{zO_$0iq%xhyVkHPkmXd$z;o` z(-Gd}d`G+%(YIKbz*q?LmrR6x^uU}iS^Tzs>GMYFI%BVmDGNI&+_u~+S0DhuZZYBRC{V~G zy$j+9*KfJtuzS^{SXPP#i@&WEq#Wv~X6*H`yI3UlaYwGq-}<-OB3Lfushx zfRTS+h3zSE?5BK5K~U9N0CWI+BTNj5^xB;pC7jzX)d_Y&Dt=e}tB(aXh_I{DKEo_) z0r-KPibI{VY|3P$Umm&K+pQ#zjM9}=%4Hn=j-83?)Z3#C9j^ZqUr#|9vnN zouz|*J`#mMV{e8&_z#3xZMrC!rYA8_n%g=_NhiUKr`vl%qW3 zUxY+6B3<=t{8tTcoe=&3gcbs2mQOMk777hr&+z4MU#b1jN9E!1IVq?Iu|R~9IKpQL z{E4eK^&f*D5#a5&o7=JV|4wvC7s(P5ktCOa_(6@lNg`id7ta<;(2|aV$p7$H_^}YdHdd%eE0dSOX&POhw(*FPvC@%gp7oYgCNMr zf}n^Sk?EaH$x%v*iq^8IqLQMhqLQMhqLQMhqLQMhqL73jMHNL7Op-}5NhHZ6lO&Q% zl28h%i_QKU;0l6#;);qg5+sQtNRlLqA^{;GAt50(UPkxXd)+?6?q;t#F89c&-`+R1 z;CfJVE3nS(^2yS=J*KR$kCU%7bU0Cs&A->W}i{#$Z-C+1D(#%W9}A~?xEzU!ScPeI6hABC%Qd`myOEdU|} zKo_0`4Q3D#5F#B;YZzN;9PQNJV)_j|a%EylgA+5xehkd6%hg; z2xQP_UEgAgS8les7A2c`hIK~MSoK+Bd&*^WeTE~v^)FKD zM>m5#3M5ayjj*tjhQ5++B&=Or+9$Dz_}>>2CcuV!klp{)a!a6Oe@?!^R#;C{=#Ze) zTMMS5CORLD6-;~x33s~S#s8@*p3u6x?n>M#_ zOWHFRCo+2$#bU9|;x$I)$o|H9w-KNG*g5nPolc9RL z9nSlyMQ1OOmIXz*hv&e?yr&CPP{5VcI5EFeGuubiBa>$4m8Njc0u zD3B#>7uHDFAV7f;5pMJhaBiAbLkkm_9Oi-rR@lFx%l8e(ohMIZ(Km$II~PqUZmH2X zN^~B@Q4*F(+FUyghGJLZd;lr72i+UGgEJCmEtzIMjNDr>*seLl%P#Ab$8Hdlgq2IQ zslZ9;`rS{S8miVH5PV@(0u&H13;g~(@hsM)b**ju)`;Y-uwU}%j@|l zj^xvT6E5Na5MTl?ET=~9*DKEIVdbtZ&m!%p#ccPQ-Z$0l z@n2R~pWt?UC?aAoGglYMf)rXp|Ak3rLOccl0*$f?HQ5{>K=_C_N&$qP>A=0LbRYns zf0S1J^dPk#3qFkB#buS^)WASU&wKA9rwI^}?tW4A^bf1xyx1!hKk z^}b8$rL>1xcF^X#pDAnk1?Y#ke!LJ)t4&f+Z^YC!SKDutVB+Hy^QguyBh6-{6QdXg z)Br>X5ns@;X@CLb05f$|owZ1WDs1X*BKk)F_|(69jltt-`5)R83}$i##%! zqwMV~6+Q5|{DqKOa06bolNX|tMWIW5y|j=b00P^4&}y5|b)0a&5;>Or^ph!^Tv%n@!fzZ5zI zm3xO~Z0_@)d1IsC#!>8X8s;|V?`vdP<^N}K1&I0joK9!G=;chQ^z=Sz25i?>es{8k zv6Ii$3jN%!ic9eB71r>#-~d3m8Ln<;1c(s; zQhLKfh(;3`0tfs%$_%^TDrtcb53H!(e$tW8zYb+bfDsKnq9DyM0McsVd=COdP%0aa zi>uRHZ5i<-UUQGd*MF>sXW^iNetqkc>~VmD+X5l!>U+Oa-Q9S+M%S9j^S*`%s`{&~ z+v&P(xlTsU+K6fhf}?}Xem z*bQTX`yG#2>3|3mXvj@Kh=48W=N@%wTD!h@s;!;b_cyjZPZ#A(IceisDs|8H849Yi zolZW{!3=y?O$2W15v6ptT!+a@JAeVi*vUStj(c``qG|&O)1lc>eCJ-K;_}8q?`l~wH;~S-!R=gk~mC$<$T0%cX&}v(sC3QtDNopwgJsy(dfy4jrOQZO5 zzV|1$TIBt{jHzu5mSs!OHPJbfb?C64Kcu$n`lk-{GA;9HA>BJ)U7t(>08*Y}Hbg)T zOz`q%PN>dgOl>`)w-6Ol%9%mc#pJ~OLVhryRB-yksf}_;cuRS+4qh%iOs#6d$%vMT zBq$4_=;|<*sd|@$sv=?c)R86uf1%_=T$EuIF9Kilwo`dXn9L;vE|B0br9hm(kxT(` zB0^vhfJF)$cX5-his+$2?30V{crB~8zyj2@G0_-QtV+%o6M^%&Jga#1c`fT1*Y}(U zHH5}tInH@ePwz#2L2*?G?J02+!|m8JOILZKW3sn$UEW7xO;8QGA5}~9VP4(Yk(;%C zaNfj5$81MAVsPVKuj3vSLvrU7QeD% z2ghxuSNT~e%kuv>n3e3eMcSg&`s)j zR`)!8&$;S&xqmYKbI@;D@9}s`2u}0_Pj9}u<+B`5E4W)0<3%v!QdUl%!ELKc=aG}X z#7QN(Hxx^Rlu;$B;ZecsSO`V8sd_nbf@H||D;0-}x8%0pgVW)p)#eLFoXvAjy3^rh zSJ+=7g83Lx_$rQUFu_}uO4ZyOSc}x4su4g`fLaa2I?c5%6jW@a$sEDV8xU)8jL*OO!c)zn_DI`b+P8 z5Dx4MZf8YIQ#;f1hg~n(-e6Vlc5fKU53^oMJ{X{>XxTLf|06XdDnPTjG=m$gnbYWS8D2QkcS=|h> z+;N(-6R2MQ4hjvwnk|zT%t+h0RE}9K5onA8DoT?2TO+TL zAvlw0D~Po}Fz9Fg4Io|H0yPVx`S1?3G2%D<8f7%OYNT>)o+(l$nBtD)p zQN35oYnW)~+11ZyYw{rHrnC*p%5s_6JjOzl+H6(kAKc$V3l1VGGP@5}fVuYRZ<1O1 zlE_`3{dmaf(|O&{x^8nPwGl&B!^tAfh{Y`YW)9eV%^BSI>9v_wG7HW3Xc2lpEdv40 zMLdSsA1$Qo$h^*1ukMbq{##fLleB(1L?dGtwe9@*(Vdg;D?bk{R*!ie$d;A_(4i6_ zonPGK`qW9bjP#L=EsJ8$<1X-dns8g)_T#a>OUWgz=3e*R;9U+Fji00FHM$6QzG z=e{2OOTVt>ING$c!LB{B#`;7znWG(E?i@r+<1bUYe|(<@ypCzs_P2;NINUhpDP`3J zHj^jC(-Q9EbdjZsPFmYo{ib+&73jL4m>c>x^@*)jG(x%WeP{3Qsr3e@0Rf}=!5ki^ z~c^hPA*LefXER77k9lY2KN1>cG230)^?c z=k7N=pT6~T%a|?h-$o)dX)_Rwwk-$e?s!gb;s1cZ?Z5K(jnpB+&@g|nzS|e$8e5h$ z>n1$c)*9|r`LY^nYmnjY^4;HMJF=hFr7-%?^=(hczmHY#G3&~*m$t)e(RA_~Wf-cva60F+CJp$%?)$!0mZRB8bTfVvWcEJ()>^0!8wOAM^4f_cqgWn+0%bFZ>U2tx zfvk&#$P=lvEXd9$9@a*8zy&h|0RS^4UudqR&|hqtON**1$U%c*j(ajdwZ~hxnnvC905N$$4&$R zeLCyoF!C}^edO;HW)=Vi<nURsv+#dLL}oe?ofGBB#wzEz8Wb0>G#MUM^)#`(h$680om{jL)y6ADC3}wvC-HmA)IM zb6>N*u zKQV9nbY^DgSL2nv+l!=V>xX`U>Lc=j;u+`0A0ZTh00=e+)dTAXeWL(9EKOT|>uTydkdJfu)X6`t8IoG1MC`P*(>*Szz>6)--Zf#~qqL&Fh7|C?F} zw@8?A0S8T^-##J!a0QA*0g!|t2tp8$Kv4aDPYQaU3W>9#FB|{Zi|@O@ZoliK`a2&V z)BAI`cYE(z~ z7GEqH_J`Vu&P-pb`a48*`{vP3VCC^!DQ$2dV?TOT^hFT@CVAiz&71Iq0fVK9bD4|Cnl3Gc&a z$8v7w9Q{57o)RdFNHB zySz(;iI?+pDN#nWFQKD@r@x-`@d5(I7hBddpTT8~w|?E9qtq;7aQXCe-c!aZrp3BM z$#e(KwpJoUUUI|43F_{%6#1GP>7QZC)|;kZ2K5`R%A`6G76f4c0ipy5Ee+a0shYH7 zJ%4B9;j}y9Bw|q@vbW@SK3l`*v0CrL5NAAW*L)OC#}_}G^<>a(<9kRJW)d3#t(&VG+cG<{w*e)^@`-U~g zNH(<8bhEZSHZ#tdydF`(X#^CyANIA{q=WROd90*J-Erw;9gn^0mDGSEjwFsl%5<4E{xj$rdvj=D zmB303R(c=hA-1)Y4VH`PHMIP|`{%7I&~~poe=JM#dFyNy?lXENcI#)y*4BHQ@-X{+ zNR@bNXKjEmkhiV2u4hnHjQusq$LjPHP3k@c7*5 z1NUSbw}n^Augc1{%(|W*`yuCES?wccZ5_cD&wIa*>uEN^MXnL~b8)@q{28|p-Fg^U zbEd$9;V3N4-|!D!K@^}K48*;M_18x;<&osPcHtR800;t-aDmvtQ8Z`uY>_>cDL>0x znpfjoK&+o&>A1NtRi12X50nH3cI|3GF^)l{MQ6#yPOtm_NPjF$`S#MVdH1AuoZ@lW zFU5^^Vh%c6YC4(wKXdT9~Hyl!547(rqgj+fbozN)q7{YwSCX` z_^kk-0p-P0Dl9qz8?D%ZNGNx=y9xO#J?J7?l3?lnaot^k4_d|5VG2|_oc8C;*@SUc ze2$L`s#ydcg%lsX!Mq*53#FJ0#}XYk-&9*`CJ+@1v_;DJ0-~JG3fgQoMJ=|G2f5}w zJx0iiCb_p<`=z)Fn$45HVyJfhmdm6NMXW&2sw6}Moc$R+%s@F0fB_V;sxQOo=PpFf;Y(NNzh&auU4|iK2VJLIa`x`Q1u~g@wmz^&nuvjs z(On8%0JZ+==u`-yuTdn3zp0F68F!@G9{R$|z4;u-`%5M$y}cGV}lzz_j<>Fdn224K({0kDP<7#U>z(4GUY2=wY* zrj?^liH&;zb&iJ5n?Lf5F#Nm8M)(W zqaf@63GT_V)!a9WcEguRCiPT5)06LcD3qskci3So4sHNO29G zC>pKy@iIPRs;^c$@81mxl&$37%R}=2lgI==$@3GylwT7u23+s+k zduz+L9$y1r4G&<>;%ex*jCtx3NhFuQ7o*?J{NK&|-_N>dKp!C&nOo+lT$o%i=4bt2ZhZyhsXf}Bd(e~mi{`{*Q@2rHzj_~nv1B>c)zsCRI~I@2Rj&irTtF4 z@Jk*wzxs~#e=KeuU_b%@oi3Rh7SnGICgVIV9{sNHT^a~Z`a%?^SxbTw36PLZ$D+#R zSyRwg4`A&CA6kWLga8`nc2WhiDP-aVYj~VDlc0f3FUPm1(yE&=Yu>3IlzJI-zMlm2;}3^cXWFrr!|xm}V83l! zh2*%S&6pIR00@LcTm})t`-ucZ77+pkh4a){wFF`>vl9~((3UD>zXU*p+d5ia=OJrr zYjJULVPRomVPV{05j@UA1;plcC?H&$*~WDgzRfy;_CjIMHaIdJohx3m-OtLD#mv|} zX{p;5PSzo~vKZ(^5F;p<-i*}a3%z?+Q*mMEWWnsa06+pDit6MBv&U&s`rq#3>rJE{ zTC=IKRryxIG5`kH03s#dU0+6FZ$T$LOvlHBSS7~kC&PH#|37$JMO)Q35G~aoxA0W& zJBEq*`HTZ&gzm1G%cZyu+LTCGtWft~VcNoi_Kpror$zJuUhd4IGK>p{^(dLFc#TD(1vq zc8N^mAuS5EE+1+x_zyu31;E6~#YcWqauQ6B2004coKd^O~3}IAEiIFl6ojP>s z))uZyzpsXx0RkMI45{=Madx&fN0Z|0rB_co1FKBb->uVLix>g~MU^-;(Q3HvY|$!c z*$qh_s2o|VM8#HLTwlE6NcciPkOUErPkI~<1uhh}X^w0n+9Wd?Z2A!ZM74f?)jd=F z*Cmef)5|{}lVwnw)o88142L66&QAJzn~XJ8BqQ%)9)ezg*ql~QZ%X)VKc|ZF=964j z*8@M?}y@O!)(sKr*|sMab;2ZBX%k*2~;#MtHD zVlF&#eDq)dGVIL|Aj<>l)iN+|0XHqw^-BN0z$seVw`aqrP`J177B*1-Fb00;u5uOI+s>cvnXqvFKy zPQ~CeZKUH5kZ?@}VICL+cs&PBojP>w?r4Y*%Oda%kN_ZNW#%u_CrXpoIUiWmb1bQX zl#Ct$fQWIC;$)|M7Vmy7$pAz^5T$C>l9ojP>vPI;bl15-;Dte@G40df06+>QE-*mM2sumFe-7O$5V$5#!Y zz(f=P081>&V07%ZPrm78 zBuceQgz$P7%aMp{Taovynu_p1rsnk>MBbb1ClmH>0{cA;S+|%}q47s`sFCB5R=wXM zA^;G1qvC=f2zjl6j(s83FWbCOAV`0EKi)_;Q5lD>bzH~W=lGt()X8bu zewnd+b~ztvi^6c!7ivk%QfPeIhWo()ZtkJZ$0lQSGqt@&R|K*I2#AlKPjq?<#<0t8 z&-8woK6VRv2%k6Gz_m%Hn$&11T65pV@**JXKIAcg0D&|aNrr;nquO<#_TjK5;mmLI z4#$hxhR8M;hB`iTeed`vpZAJBJZW3Hf0s|u*TW@2W!P1v>H+|+brsrBKn#RM_HZ*` zYCL%OPM(Y(hY^K~iTBwr)%BAx&M29#+2N33FID-9nXSLP_@tQ?oJx7IM~{^`z|&~Uq+005S+N%)fGqm=ML zaBm>6TbO$@7d>~xu+A}ic@{Mx#1J6+90&r?z%g&yl@YT#k4_?&1_y@^x7=)=;BQeY zDx8qjliQN29#5yaF`3n#mIIesc!2;J4-XZU6>`GZ<)+rL(pk8j;qNOCoU`^6ABVx#`e0;oTP6EH z+23R+Oj}pI~u+cvRV4{75gP|JoAwuAGN*eWv}Co?~aXQ>D=+Q8;U&SM&lMIDrCsWWdrZ| z?EP~$lGJc)H>D(mqJcsl-w9X8mi`-KMV|LMOE&-0h3tIYUlE(N#A}}ZBdX{6z1#mT z!K-pR_8|p9{7^p3S}K{Y4WaebV`DHcXSz*hH1dlwhUTtv=iL13BnXIUD7UCHHY)s8 z-FO0o$4MeD;-jZ?)_YQ8-cQe+fE~)oOtl`zt?h+pd5u6@j#N0#sOw{xI$;@aj2o6(=0G+%7Sd&6_7 zRnt|x%;*?-??b`bbI8?49l2Dqf$Ce`Q#7K_c5{C9IQAL^QB!|?PuKtkVkyhBF`wY) z`UF6NPYs^#HN@E87$e>vRE|a%^Fncff`;v3E}FU3o-7d%A`6XXvZl1LxwMMdfJ8Kk zF5P^GKm=R`Udg6deK2-_zfw)PB*BCx0tnnKnPNMv`gQbq=Ny$DIp34V0Zichxdeu< zZf}}yfAfGu2(Z=r`Cq&j4ti}twU=vW%uZk^CT!AnSqbOW!sa`ib!dT6MjBxfs*S0s zV}SGkLq-_Ay<`7^jMt+MMro^SI_7TpEgZWn0Y3aCT)>$&R$ z`d&I_k{4>fakV)Fv&&{TuX?A?C{TMaY&@~hvg$o54x199Dk6`2uN1i1z`*$<>lB{G zCYw3p_3%47nNui<&maN=fFC3LMu>nQADM%{8N@u$Wdb4quwV{1@wW$yZH?B{d#~0# zoSz?M9Nipx!QudjPJl!m6kBBRPF!AV*V-Z=>(PzeXfvP}Ey1geip&94)PO)5W{ZVd zgpQl%?s}x0)?YHW&hbu56C{@Ey4>4}ea| zcTo5o6o28(Wax?0S&5Atfd|_M4)u>aNepilgTi^6UVx6(_6A6$T}T)T)ppUdFxqtIfJMwDyRPI zRBr-!Jn3c@K70Rbn(!h3g<1~lrQ@pFKc~*>dbnBFZc7RlQi5eN5ZUOH7Nz^ z5fK1Gc0d3D5pvL}tTrG^g!GGyCJU%wZy~N;tA1iQ}$eYq?SmQsX zysx2Fc@F(G7AOAk4=TDx2(C^1XIg*mgQx@mA{~EF0F`%78%78@O0z;Mh=_}{aNM7V zm^cm##Qnz~c4~djW2RfGxV$*p}u^H|=Cc(92M|1!dn86VbvpSsW)ET>9x0o6R0wJ(<^i@P^ zvpOmy4oQv+4Ek1w{yM{Z&r9SqQqYJBf~D5l1IdE{Ve-6r_cRaIm)SW&{%fuCY3tl? z<6Cp`tupuYklrcmi>U}sfK&`_#$Cc2`1E$;G%v1 zPfhz^oND*KehW(8I06FtH06#n5J6RB(W+x5Zs$C3shIS=S#y^t4*JYNaXO&!?@Shxq!;V*S1j$`phDfwQL&=K#vVXBQ;Ep+nlzb}ri{Sv{TQDbBuE7R$x5 zZ>I!BOM}(TVCQ;Llg>d-yW+ToVb17a+|mm ze1n*t1!`>lbbUH++3f>_HK#sQzI)B*XOrgjwy6KeDOF4yw_V-r2c{(Mc3QV%@vrho z{iT{uJ#O+faEi)&yuH*8FHIeyo+bVX*Ugy;KAUdFJZ1tIK~& z=illOAOQeSUlrNKc@&@dCqvoosc}Fl^PoacMxXS$rUz!M1x)t33Q!PqY772VQ7DQZ z)@&LgA|;ov9$!_sJ#`5#ZeogH-_x@p6N9QuvHt%fCDq3P9`w86v!pkah`>W+d#}(8 zv8q=^+RfX1HQl(Ew7s-%YYOR3aYp8th>FTf>(02@2Y-1Y{;rsEA{U{4iwKA#nPzcu z_aN>eZl-+y(#a`z`N>zP9A!l3r{Cc}h3ec?)JIJse$Y%EdXmoIy`%#&B8UV;IEL3= z=&}u*ZKz9MPe%LWr5wo>9B56&#;y}PWbLw0A_PI%Bjc?>K+aws`R0Fbi3mvWwVbUE zZ$C3Ild`XSSL7!mR^IDn@DWnL7f*DKHtL;trunVg<+?A|y=#1@BkN{AkF|0pHV?G} z?2C$xjFlZMHi8g@2tp8uLJ))qLJ)xnL4+Vf-`@CK9!Jm7d~Z+f>qYDK^|@UiQ+Voj zObPRlgdjo?gg8Byk^da_pIXecYAB+G6j4PRHV=)i9mm8St&SU}z278|l1b|ExJ@t1 z$#s6eWCw-MZJy3HtMS)_AqYYcgdqq*5QHHJLJ;NqY{p-#&EK^x4fAus@*h`Q!hZw2 z>H8VX(llQX#-_dlcLT~|3(Sto-?F4Q{Ew807oY8%`_Sj`z#$FlG6@?~Ic! z@BNu~(dsk}iq`i2g?#_l?x6nHs6W;sP|DcR#&aVcs%p}I^?Tc59#&OK%TLEg#`*2@ zN=_ke>d68kA{uz3S!&)W1PBRQOB1V**q1!B+7VDh0T$|JBgv)Fie9^+uZhS(zTj8r z#4TQQ&q#=hYyne3eq*7{oK`y~&;SEXP~0Ufd=s+gmsG{4y*ADC!%NnY07WPNO>dD$ zhi8O@dI198w^cnYDEj}`{~o`?yKwlYf3u0gTjT#Uuy1U;z_7Xghz7cMr?e6g-=;Oq zzF+_W6kd}2h=B=A#7y7-md57$1?yn=*y{B>JFu!TjgM<>7+A6wX2?`-A#j5>|oKq3yQ3t6MRub*KO&F*VKe?E$Y9XjwnN$bVlN)G}C z7su4J&nJ8o4-j+S`)y()S`mxL-%JgEufxUTo<>&J^Z!{cIE^ecZd;&;Qu5zV)t~;y z-ctBh-6|G~W3q8e+Azk$atr(afB>5Dh=Bt6Okye1HuGJtYw7J-9*fBSct9j;^pKK2 zMt=*q*FtmpjJ!qWhkl37&mY@p&94!z=lQI`mqSXMHM#+H-cMrXL2k4AVunb_xmgZi zz(gFR_&zAFu1uCXHhoMO?IQ`|8?Sc~Lqlca;dkD#zyLw4QuY@U8nmlx>$LU;+!t-w z&l@z9mAP;5&R4&SUt7L?qXDa^rmuf`YVVo}5;gaJmFtjKMl;iSgt!2K0U~)p^T|Sq z%cDX!Kg>npQjj1XtC%zD)Cm#&vB&eb4Z+5u#Fl`aLuA-{y zzb5lha#m~Dtr8%{(Xe=wTkyVY5GFCLEumWNZ(wj+90vv6sHi+VN&%7#y5+3FYQD#rn+YghCt;EhDeTvexUS%4||2 zAVzTR*C=mVOg3%R9<}yf8B2cU9vJ6ksi!N@v+1{h2!Ri?!}tIR`wCNtZ;%n4rA7qh z{|gz$7?T{gC22VX1Q_}lm5cwC-6j7SEu=O=!xAlVggz;#x&P`TAPiE~qB0C)iqx$k4uyvzAO0B0>MEm{CCcqDZXWp;i)yyWpPhqcQRY&ucGRbS3G%=oi$4q*AsqZU^R_3Fbq#n6?ByxK}IKr(5?Laq|l}4HsAizWz7Bp=4hT}V@MH&@N zWM4xeYN5R+XoMC1zC-O9hAi5+eH{|dVj84C05xASJIxec;?)aeaOHHOJNd7y%*+6` zNW$gTle9}qcWZQUF&mloZgP^ESYpq({!jWW8og^i?VBf6-$KQ~Or|w0Ph^;5Lxkz4 z9R}nOAn<+{ASw?XE+nO+q#eNi@@*DjY1s8$OI!BOCzd>G_XMnq*iwp0>iPca6jRIY zGfLCgUB}AotuMK89uDPsJpn3I)xp_rG+fi*W=2 z5jiE9bNfLqVH(%%ezSw;mrwx$0&f?!>;M4_fB*{Zebk_TkHtI#52yFshK-g|0x=^G zLEwlHM?-{Ig)LftRYtaBO0oko#iNtwl`QV#C#CGis6`mOy?-}AFOQ7(t`71(e!67$ z%lAtruS13xuuRI0G=C*br-@Q=ffSLcQd&@JnlGmtx!7bbJ39 zuV#3-ZK-dpF;zyu0kmF>F*r{a4YcMW5c%e055mV$(111KP0TmB8j^Yh*g&q4Ifh(Z7f z_T<$CUboAz{r-1^@n_V~)iS5vIM?2^l%4OnF&2pYBm>=pvf--UMJzA-mFLev`Ua)T zQ-0p~oawOVt>vY7Q=~#_Q1N^O;sr_cUxUDEORqqx`l^Vq|Y71vin!E{&B*3S3N6$rDknv^QP| zT=ZA~2caVqpg`)--3f!DfLRiV*R$&yj(XOby>rWm#3;X=Ki$@%|owHq0_xF~wNg^xFYu&ZM`$Ygi15w78tc?Hw2W?D|5CM2pWz-M^ zL{z@rIVFecpERbbAAj5VZ{Cmd%dhID&o)myO>M zjzGuxvfJ8#EPUlO;&Ewl4b6;qG940v$H(LgV zUXPacIXB+h?ddb}<3CVu+YE(ypJKAJgLdZuVPE9*tql?H@fLf*&c5-9dLq6R5;)Yh zzLjf2A_P$9^UVZ6f_;k2qJKi?DFpZe07s#lZZdDL{eJ`iIic-&dLcR%r*iowfQ*1< z!zmk@*{!eo#KeZovgx{J-lOFCdtKE&O{PK%mfC;Ssa$4&&q7^AF>2h`M<^HwhlWyJ zC$`wu$5eWO52J|c(c)g19Bo_ho16}v} z@Tt%c5#s>>0kkx`d*<cYIZm`jjbR7eY(=UtOSJuZVbK&=hkLFZ&yjL}c%)I43 zZ;Dc4zH&VNcWdcJ>qO?k5dtc83WOmrCSy>^b$$oa=s7%qXNq~+1VlQ*Gjkbg*n|H^ z&HsAIXdeb2Wfhb~O&_K-{Lz&Oef~6e&yP&airejn+G5DNWX?@MKG&tIWSZ3OjM~AT zsi#&(P29#n<=_o>uv6no0XY2?%LzYij&;TRV-@67)@L@e4KDIbamH+lkU9bYfERDE zz=((tilevrI{kFj7LCCuIzYfx!*m(Bx&DYCMDbBetBpE{E?7+`M<*J8LB-&IC|BSS zJJ6{g8>n=u6P#A?lq{IxELW@hP@^!I#6ZKu()ReGaV`&9heE&i2f&!I1M$h@1UoV<&Qw2JC) z>ol}hJ5PhOOP5~btZlA|L;@loW<9kL@vf(w(iS6xs%ZVjE#mTo2n;^)j*Cw`nhm>& zl4`Qc`Q0oNvJ$l2@2ng?HIxt_MRB6)QeK5F%=(X^4ogTiY?%__^3-uqf|Qp44uA5|n;j<2Ku! zZhJ1^vhnl(rRF~M?)iTIU%3e|Aa0d5Fj>kNsD*5$M5%A4j3l{j4WunS3yre&U&p#- zVphAcTg?OjHxR0P+1no{j969R{!g}`JgQtxk6QFCTV^c$lV>y(TdK;9bka32v5*7-3N`aB{^GaH`Z8zuQ<;e`PKABZI}%wS5CviZ z5GkGFpY{crWGMTXLJh00$Frh#)bVbQWXaHp;m!g`)N)2Fz%e)XdjJ6whyaKXB6;H) zL~#wPw$aZCH|g7gxH`QX`ot}2QasFX8x48AK3DAmwxA!>YPOG^B`UpVDUkNpJu-x= zOhAt_c3qr?OSXCl!VwR=BmdL{Lu*uB;`vf4d!P!CR8!VeQ^u>NtVJ_G0gwPiD5@?~ z^WUA@J$32y+fDz}wZ7=^TUQaiYaG9AlGt!O9tlAQHYyv11OX`l>kv{%Io;(Fah4Xk z#tN~Mq9Qx3)Aq3O1+DLe=q@RV&fEe|3sX@eh}r&J-cxGPtu zc0|Sd|7=!uvSAh@@1k0Cu9(aQ=Dk#FmyIt`)b!u#50G{-e>v!I##gACm!DKjhgpXy z!E=9kZ0raEpS7F)`QWhLC8#)apbwqPr~)2Kb-t;x*j@4yV;xnqszPniXW61rhU|aW zI-3*9-(^&g3nU0oI3u{F05dq`I!bmHbGkKp65X}Gln=g)6>egv8xe<9QTQODrriUI z{XaEZ&pm|M>j2mC96!@xa()PkNcDWiz6wnO{PMzqk}%W7w^vL?S{MKTXQH^wDE-m| z2Vc#KG<-E>!d!!P@^X(49tZwOcMy4KTx3i$`3oIGmcrh$IXjK$-h%-UlzYX$N+QvB zUiP=DyxhLc>tFrbSIfobbHCqh@ofhyG%*rMgRD01+(=YO7y^-igQpYwxq%g*REGffzEr*|B<z_PG;T;vjEOX4UQV zYH=s15J1@L?08|?N6R8&&TwGKGa69L{uMhtb#3K7|HB8I00HQ8U_;r&(&yl&G-cXn z4*cs0&{7lXv(Sq0Hoy5|?&%U~@&;|oElqAT&uU`pEQotok)>^>;P@NfB+jF=W00Nc zwR_jS3!83(*PZi;p@(yqc!9!w!kXV@wpz)Da1ou%GLjT54W_1^KBu#2gg}S~e6g3! zC>gC;o4Cb~B)8c$VGbWM0GC1sfA+C?R_NZRNd+#Xg*!7_&;(G_kt^Ge9!uu`Uu^?4 zo$4+*gYRFW^R9ROW2~5onAv6`$MeF?)x#!FJ9!HB3e)^W`pJsweG8NwG`SH19PG_l z00YJoJml4lJ@#==>$kx0Ena1Jdv|0!myBHv!p!j)U>hfZ0fUq@_NXtBrjK#m{U87( z?{K;n(WQK{;vpPgeINF}c`W_g&BjglwEaC)1=S`m$4DitY*d~(SE7@roCLhZGC_Dsmen^w%@JcOQGcODI_<2&F@Ub zmeWM1YU{@s_?c$G06=K<8zLeif_H=dLv-&-u??JBq;Yv%C857z#??&Gs_P%FxXBu& z>#|eaYRwL-=C_8`Vpq=`XOE_KIbp{_m22nc zox?G5)Tfp>w`5~Vm20*~7ms@qyQP=QReRCRL;5Qq2*qU@wZq=VLc+u1r>U=t4MF3Y zS3KK%u0F{QxwtvhhuhA3|27dh9Q%=bBG9Fe+*LWjP+>LHziOLr*v8N=c>Po~<~^qC zHdJk1vWMA8M?AsIaK&+eM03Z~ur;CZN}2!wWp`kQvHbsdU~MCNpH{e+X=O4@XMs9) z6AaX|x$?sy?)LiK>;fu3t_zF$mb}DpEV)Pv!mU^Z^0`VXdy^aIW3D zupRdGi+*mZDdqqq%F%dHowaM;e5@bNd6nXCyR4Lc?zCgE`L{i;Ts6*3_Nn!EFX3JM z$e$98Z%q-H{s06*hTc6Q#prjMZM_X58S%UPU>QF*61>@sj7oeYOr(L8nu$l0#CD}l zH=O4-RO|X>@Bj#Hapj_ai_~yF;dYfW&tD%qgpb~4_IJ0K$%et0tHjm22254Zt5~qj zuz5n)0s+CEW~aB%*LDo=Pmh&()6IHE%qE-G*PDWZhp99kU@MfDKMu{2*sW?8qgUrx z*>=+$AMuhh7X&4v*-TMNl?~S2g~N{@y=m*e@EK@2uFJV{09qeVwloen)PKk51~(CcZ%{&2d?XQ> z^+aPEd>+TEjmzJYuybSRr}vrKE@%J@{VubwN$FdGlSbt-D`#u;jvQ19E>W+27oTVMY>)O)GH2x}=FxFM30;*;rcExU zP9L_(76AY9cI7C2(3a}t+^G}AWhdoHQFz2UD_||&+I@fnY^G6_< z@=RJft;egCS3HI~g3loDEc1$6mAt+H0D869u(M{RQTgQkyJjqX!vWQ>0TI(ih=>6) zYIXA}{qoqn#5i!FcXFE(zNr67{K~}{rlAGZIZIZNgK zOTM-H-coaA~ zQyZWG58YL3Y)|KkQPS-BXFf!ee598vEFaO6_%D#HQD~hR%I>qT9p8K0)Uc&Or89S= zYL!^LdkMU@X763*0_$giU9+K~_*-M^*C2RZEA#wCzCNA->VV8Lx+Z{mryBhk{-RFv z;k4T_>2#{yw2yZxi$7UPd2b~Y+nFL_i<$MNUFr~$lxAp-Wc_)G*^4PN)ierYc|Wul zX9}Gmrz7E@*S+`jKgi6D;vuRZ!WSYxjc9YWu>5a1KSyKhR+QnLo&W)omYbfpkDq0r zeb}Mv=X&{VDqfC`ny`11{bt$!aD4fQ-Hs%O6N5I}TNYxisi_#XZPa`I+1~B*IiTWo5vB8H{4XuL z9X_j~hc5LeO#S-k$;9zCTN=luk-vI)Vg1h7C$-Y0ZK*VFCz@A_t%$ca+VW zCtqUHoBfR2;gr+=ObU4IR`sV|{f$3>Zh+5)*f0PM^12_qY_4u+GLm{G=g(DldVQXC z$L2dOC4u2|WVtjH8OJZ*eI0t+J5jlY1INDgFe=^#J1Op89_h=op*!?cwl+ zl2zSg8HKS@55+3 zt?&9SFi`|RhZH)!lef}pii7`uH@u*{lByhsQC8{iex+Uh?@OBYjIPP8Xr(aGkUG5d zLXVy=26_t>B$NyyQ)S$B5?`WN4-v=^2z>+_3J;G4E$2$@G}4&7@-4lyp{6PYENYYv zYb~gzAP_wkwQOEKAe8!cc@$cs7fg!9UXG+y*MQsL;c3yDxSch{X>_Do#QW~}6<)lAiP{hI z5tgTZ-CaO1t-JlZGVn`}Mm4OpZp+;7YyLXAheQbx)aB-w+Lfi>W>^zkGvxc-di##9 zo2c8pGH1&dcY28S!OPkxL4oQ1l|3%Ssdagn#^q-wzVB|=>j=bXx{fB4Eq_pZcn+1` z6^u)4pXa~DaRsqj44y-FG7Fii?Y9sZCj}_U5aci7;V=hE$7jKJV{9`I~3?39ae6s!HvYIB?>f{S% z91sWqMI^)fX-@)Rya}B-Nc}uv??t}FBc}gf4KYFcx}&e-LG@uw{O;GG{@pmIMwpuh zJ1mj_5e7G}hWZQv!;`yPGa|6Hu1Zv7hk)E!GTeHbu4^uIUze{N6N6H8!-2QO3^e-R zb^0DHmyw>q2Q?#InGy*AGME)ldB}2H!6;|NdfsaCK(|o@P5jCqYC|dnw@{E85a3| z8#8mqNPpWK*hGvA{dy%TcZ@PjDH46I$NRT^!#nD2He>9~X_qTAZ|M1n za@?THPN3n{pVL@t>uLb@%-WrfR~GXaQ#UNM20~Ew9;fab|4XvwZ}^+-MB6FhmuZAW z-Ol58x~T6y1fD+6F@3yiy^>i=NmTUKa-HZetJ{{QgIc4oOc>Y)Y}2n%*&_1X7WE%I z`y7|&2t4Vq(Tkj8z&DU;;=>#O5dtq>KR;FZv6@Uq3pY=PH=P>Vit6&m zbtTb7*I^>)@Zv(u^$2|GGsWV>d2epOs{HNhGN6IP_E12CS*aY1;SeDn;4B8+=dd2F zeBbv_{nCj!dPR!Qs=lf9oKRw|(fKcApf|RgUTpXPG^edz?^N47X%Ib?JomdY(0n3t zR)6a7>)Xuggo@(626}QC|Lxg%x2RNn7`-~-!`n6{J_u41&NWu*|9zK}Q7<{SeYk?r z@my9<#^q2v@!B)Z^^i>&4o&xEr&_m`uaFxH0rUll=~N3}s224wfgAP6sEU~7?3h+y5$3Aly6L94pu5J7 za8AcVa4lyJ`77OLRM#U>T!yb{uGLlRnPxL^y#~vJyNsSB*dZ+#Mm?9zsO<;iot(Gg^wjSF=(pUgofCjGja?_Ei_fD6wao>UM z-Q#g=AZhI;yM3=O)p5~{vkMQVp_+pOUSMXsY4maH&^g$1P;alwDsLWOyr+N2cS`h< zRTIdh&M*eKXy5@9FPGT}XE@!?+rDj{>TW+xX4i@Wqz3aX4hD7Vj~#K>TBkeC zG98BtJ+G0F{Xn8|A-iAV7n(~9eShC}J%LF2k)yN3-AjMq02MKPl-g!!2|I2xHt-*I z#tg!`{godh^RQ>#geCtMC&JVF_^(mghX+Tm%n!=BS2cl?^ofzooFSt7m7#%TWtSv*#0$lTTPD*b$%m(J(bI0KR&l6+AVgl8Jzw=j-do|X*?>ev zeM70zvgu}?%wB%}kq3*n^5p_o3bQtS z(({oK6Yho~<87OiO;qpex=WWmG=AMvqUL`;4W}s!EHvrc1@+V#7Mwm7zq@V#0S8-Y zyT4(3GAAqPj-;o}Yl%jf(NkA>M>5UUs$lil`VGmqkGJh+K7tf=KA#hQy?$!Zut|7% zFV1Is=qT^KD{w33l8b2q;nR0*<2Tn%lcu*N{QDdVew{^^jY~4keMub81GVoyI_H|$ zDD>x>G!pmSrC<8aSE+w|+AC=o8aYUiUUg2Z{Ig$bp+onUGbM9nO`~E(`ZR zDIkHE|@xx^Kqr{H%@j2DMk z%pod8B*=IbC+2k^5n_fD#4hSBA9ySmE&Omib#go~w}ax;8kzhr0je+p68VEgyf6)E zfft2-KWy_(Ef_xuc8eWK7Jau^^|k!zpTDc9mPlO>8K1e&R@s5AiOD)~WSYL{M#b$# zr1$Lt=H6nxBATy$kxvg+dv1C(JoMFrHeKWtdKxBAa0Els4Hu2%WFE{?|Hg{BO4rIy z!hvC>T4YYEzc$94v00cA_qfyG$d>Pe$i`@zl zZh!rWVP0=M9__esAPGSt$Viebiz3LfEQ=z@vMh@rs)8U|4hYLoC{m@UDk`aJ3W=1Y zB$-M=NtC1{nL-eO5)w=W3Q#CgfkKoD6rfP0Kq{u~+im%~|7G0#)(`FTv%fH+)f|AvM)=P?&I1aHmV-Rv_;-aG8arp@5ZjK;BPaJ3x| z+rAU0O;mU?s}KCaz}mkyX3#0V4)pRTOHc2ek%0VUx0&j&L1##-%GPF{{1`vQ+AnWw z3QKIrtkJ|m=HN{EwpZP}4ZaRMTQbsy>*2+rqLu;v$n5U+nPHizOmu8FKW+3Q%s%rz zd9i9ns^9dajer1qUEm5?2uOarcJmw4;n;BUm7V&Gc3ZAeELL8I|F=_EtMXK$RC+c* zh=>9d%%0beZdUN*6DjZ_Akc*YIBWs{07L-?J^7=aQ_rqS-}UKpGgtBAs?h=|=AD&Yu93si4FD4uo6(?uMDqP-bV?Zf zv^C-L{MKwW_I@+SHBln;zxhFCey>qS+IT8GN4kcAsGZS6;5KnO|yEDB%G@5w$ZK#NW z6baQLA|Wz=AJ^a(BgN&4}X=K)Wps%w(_dLBRkX7;HY{+|+B9hWSLnhfe)l_!r!DJkvd>0;tDN^@C5 z84IQ9aV$KY*1r9_GOn(h1IcWeMvc^DOoj7N`!X+sTWY8qo!Ydc=% z^@nZ-L4^h9ogksl^d$%9ujK2OOYUm+GGDO!mCB3IfCz|QwL73D1EJi}zJ=x1EVJUz zsxS57Ey_>wU3;0N%b@dOR&gU0QKGd<*W^GEXzJ^K=C`y%R?H@i2X8+wr(}(o*+|d8 z@cfx{LWD*Jl+@m$VXc|lbVxoM#&pf8U=D+p)Z5vx&?gSVB z018TmbY=*}@R&Vyo!e@!tfQTK^;UAoQ$ASM^cHGXUVm;AfybmPH%+;Ucy21Fv zwef8S)6r#&>-dO&(k0eSUd3;>I%C!R56mzG0C@x}fe;1(c1!{xNG3i5>|u3DB1d?d zK#3oFh55ML8YIDP_??d};`QHI5CDKA3^Ke51AN@xdcFsmn#w@He}lB64FA_R5I4eU-~z_V*I_+jjy-6ZX0H<+9RMBXS#{ z)VS?Vl3Rk@#}0A7&qpR^fg4zyV5iT2orv^f%Y;LIcY& z2hXW7e$D61*E|%YmS<;%g`uZDV9!+8EQvOayhPXYVpjS;G=6k#{V+ zzP`3;AEI)($265HYdDcp=uyus-`BZK)0*^{QQBZYJashxgUiN0YXnX^`;u#8`cLm? zTed~6NJhh7)AQ+Dd%mQ;M2ha;U9zb9RpMzr)eU-wPdwhC+z}e>IhS4f(_UjEH4}!u zWHPv|-{Hgiw(lptByqAq=jFEC$7{>_GqLq`2q7Tt*usD z^yR%9hy)0WO&cvE-m*cf>9(bYtgwKTlhH!zA;D1^KYtKE19-2J#}d)=iMA_~$A@eZ zs@LDDM-suCp4Wf%P!zky-}$};cfYs4y;uzNo^?C|K`1%}5+j}LHhUds`B=(SDnKAg zDh9%LztI2@5CjsyyJY&Hi5eRSk4~pB>A$3-vYE}aFHUQ;0}&G*9^c)x?c-RZYPeq} zbc?>~Dlk+p@Q$T} zyCB7XP^H!lWmf62!p5B}g!bhRhe?d85o<*RxarthhCO4VS4U$}*L6-#JZ=w9a|XB} zXi!XfIp`BiJ%jHagOA>}+V}-R8iNs!rrjN@Wn&MBk*HC=u;5l3l`%s3pPy5N9lmz^ zUhcJ=%q!*OdeNLP2QfC*nP_B^@F;mAp3gtop07)Jo06h&Q_Feui?@PPn z_nrR>PdiP-`8|)T>-V;`|NFfAyvEQwG>ak>iAFFfB0>aZ0E}rOl!YBrvFMKz!_VE| zh0LTJwM_9j-3DuLG!+J!j(Kgh%@q@V2&>P zzLalA9s1WsqEo3wv7a^gXxlu@s$yT{Yd5pbH%w^!pFQKXy{dK*2=CQoTiIUOGkmD+ zVYdZBF%EtOD0|QvVb$Fubf8-EHU*+m&ewLOjq<8`U{A^5kudZ&*%#ONb@w9x5atjO zpn;LV!x#+>>q)KQ0&|hu>mWZaLni^o!hsmZ?YK)~0o-&J-#(*Y#AG!)n#=Z!{M0i+ zeN7^=#T#lfP5}Zg@<5THjxyj*DLAEP39e9HNTXGpI=}zEr-*Hgzv6EE_1G8ecIss@ z&ZAms&FFoeIi@>Ei9Kpm!dgIZA^;Et=RU?y5aG%pKkbsPdnwF@0uCM zL+*LZPmNRD?K$gU+IY87CM|5vVM4|_zMS1v^`N_U{Z*?QN`(BAbT&~e&JN?OUPT_X zv@v|d;^upH5s;Lv1aEI;<+=zO#`_+HYd2tcD-sKixwHfTAPSbt(IIssF`Y2O3Ey$QZI_tWkAkRom_-;KW1;|(Ef7_(sZAm_9y=<6?qH&G;FU1 z+N+)YA6AOU0#tYQWxMheIP`tgM5aG-;x02^;q&?Fg#<)8n7CK9UuO)0{Bk9~)-Ib5 zA3OFl#0U_wkCl0bzj+S;Qpma6lo}0-_T9I!^{Q9uNDaK1IU;e`#g0-lpVm|-@urzg z>bst6q^P_A5pFQ6UH2Y>KrxE^%QN4wAmld>D*t-^(qz~m00dd5*=xiW-LIOH@5Jgi zsL6)5KBgTC#C~q5;r~bLTxw~PG(89Dhhk!6C^g65ZQ+utKIj`Vf`n(s=JM+eh(>vK zBWQ97fB*~t*$^)K^0<0(ZVZxgfpOA6pX5f_`b_rDC> z@(5232In@M72o6e_r<*bFo=vlAMkU27k#?OjlcH3<=F8v-8NB7^tru;1x`qB0}$QT(^?>?$zpI$8j^O4A)y_vbA?I_Ieyqy9>X9P3?F1`DRt2!NNq zzBqsYKX!lHVWjBW@=S|gZlpz8-#Nc^WB8iurTyn-yZzX#Fqe<`%?z8jRv*g&&4w<- z5CH`-pa23VP{;rSZ(HB2)0i_7w(ZXPt}jTe5fKm|5PX|tLV2&*IGl8Fm3_NF{np6i ztn*j{g?FN05_{Tb*-SP)K6~^0tmmd-cN0e&<&d6tU<3$ib3e-s{-o!nJ1Y0K#Lw0M zh>GfW*&+0`ioNzDx?jr1?YExFI8XUmQO|8Xx@%orsbfI-z`rkq_>FG$I28Z@fn_2u z@vpf(>qbXAJ+xaDJ|mLMc8fW0+ERPS-H5I7zh!IO-O?6@`1b=T=ER&W%fbr7+5T@mV z1eZMpdolt8gpV_)qHT2GGg)R6+On$*fB^sr2qdPaF`h!3KF5Uqo!uF_r-s^VS9f+E z#yj+`Qi?y3?C<)swRmT%$6sH*e^eT)Aq|X7v-4TUY_u@8dR?8tF`^HVzK#NFi|3lJ>aXuQ4Wma(G7 zh)>|SsGoB|*SfjE=rkm&RB4Gp>bcmzU{tB`B{=v5V85WD#{`6zNV z7#r5?=Bdn6-}JVB6-gsq{J;J{qwz&_H57cB^j{1*Ro{(M91tKxT!Ru|zmt`~h=>em zG6O&^-=MJ_@XGzo;MMVwR@U1!Z18UgIbELQcLIf6yDVNjLi*Ek&wrQZ1lqQe(^tYm zIR8_l@qyUixDFQenz;Nhr4WiFh5;x=2uUQ8NdQ3tiYkJos4AfeAJQR?6cGdw1Q7}d zg#7b^ypTWcB z0sss<9|AP0q^_FgYL0(@%x#JC<3%OF4pd1Vjk7a81Y89+Tb* z&l~^;Sy84*CE%}ZKKd6qHpTtuT+ANIz=pwoKiIybVJ+DwbHqS#T4~(8ME;_+6sr~X z$Qa#7dS^cKi)CtlHDkdM5C!V{B)ZVh9(OwTLcjY^(9g5Fj+#rL)!i?TY$*1H=9fo4yAwv*Ud{eUD_5 zGs<2P8TA!@0Hfh?_f+sxtK5kcZdzHYOxh;pvwhxPx8*VFEkE}DGaiZcwhD66Fo*&! zfovK5Qaj+u{krGAfB?a{N@s6V+;3>zZ6sK^fQVrCQrPAGb^Z+9w3PI%th9CNFA@%i z#kGWZjL&T|R1Vby053jQQ8AFSOKYNn3Zvdc2p4OGo6IGbsHjLlh$k?BA|etQZ!^LV zPn^$^eEte+plvHu>76#DIz6Pffp4+DnwtEvWO+4_(+Ns(ROP>@Hm8Pv9`8!SlakkW%Y^!a(>D8$_^di_Ak-COwdvv03_sMMnvJ5} zydNQK5Z@7vXr&Dy(sg62sxeONoYu0>qcyG7$vzEbHZ&jrh!9dVwDIqt^?(3?Q=dWF zAaLO3BMok0rhNC5vqUM@bG5y6kVFR4Updlz4wy0nDYOv}Q*Z!^Va}m|h=CCJd)$(~ zs2}uI;yWk8=kjLCb9~O0E8SXsvQmBVw^yx2ja1_B|Q@zE}Bcehv+{}!zLq4IA-S=3jcQ>o@I zvq?#*t7!}bpE9)p_1PiYC^YqGUEtZ@TkjV7ejfsKy++2LOK_JQm)qY!r`k-zS{zD7 za>kzFPOEil)FxX}*Z!BV^X|3m_UTjTSJ3_5aS;#-VM4r!5fyzGKP@hwF{^ti>*^JB zBk+J|%RW|Eh=>Eg!&zN!`I9UM@9u z4=&#JaLr0Sv@YVn`2uv*ML4cQds`~0dYn{_PCK1`e}d=27J>%{%Ls?_v|r&tMV)7^ zOI&G=*GWFlH#ojU_J0kJj-?~TS+JOKrvkP9YwCS{TJ54qSmj|4T z#%NJpb;&qJ+k3bbg6xi{p6bV`zVO^aeZxTQ@q3`s{BLVZi3>0)7U7J zGMQ|6oAEqM;n2sKTZfw3G#&HX@x2j~FDCn0`j-Wy%Z4PRg}`5zeaDEih7gEihh)^? zn43GF-c}d=ok#!x5!7p&`zM2W^fxR$l#dLSTbPf*+so)d&RkUie}r0lzI}NhuA7Y= zEfOuLJ~}`^fL5$R)!8HJ7``=M9oX0We#ApUL_mYJm%4M*bchiBTG-CvF}Ccjwlk16 zOUW``{rx?6n~U%jP-szVy$c-5pI$oDk;*54 z0Do5z-oRU{tec7mrSJd)xlO_B;s8JkES+fp0=-ICL?juVb|L`1>38U7|MvGn_0+_v zBz4+ONFZE>wo2-hUKtS((s%??7%X=12mut6-N~lX?)5)gTXi@*54W+^R`DZDsi;ka zA|gM9zH^{NNwhOxO-0#Wxz5i~yxSmye)r6<2!;cXZsHbT(XIyc(V&-kWQz=bcxxSi z;k`%z5F!Wrt4RQCh=>s?2h?N~e02GlAcxN6r)Mi4G&b^8xtv&yUhlG5M$M%rZViy1#-f z9Jaoyg*0`J>qW~&01zQSYr{s=mT4jo)NHw>+cQ*3EWU#_ePjAH5;>w*<~wU6x_b8< znsrg=9II0JK&)K}U%v4G&IPnL*+Q%~nFTp8&io*-kf4h)j{>#_6^5rl=VvG@*}seW zlj<-V)7MibUAryn93D5JdZBaSqdo%F%FQtTSpvk7J_*XAH{fIdK!h82C(~P9jh%XR zEh0`*gZowjKRW&#ymScs6dkAcTpybF&M>Y|r@v&nuim4GL_{DQk^u*#T+p0naZ329 zOt<)>t@Jb!ba_aLX{~0P_UK4z+3r%uA&?8#mG^z^tNnUiQ!<91rBJm)xP|oH^;mr0ATU)CiqM*% z?B_Abc-E|N@lrqSz5)Z4T4Sr~<{GCMmR*&m#MHis*Rws*H(mX1rI*PoTUH~Ul2XyL zQt?0lfjMe?(%#6g-VztMl{8F`eca$Qm<}(q=0BCTzj9$nLJ%PcLKzHxCyLs!drv{_ za+<7?5=k$Yu7}6C@=li{yX*Aqb&6B#{ASV9>|1s4zL10^aPqe$9EScIkIoWw)A|iOxoOv_1a>jBS$LqfT3r0i;1FX+c zM$(N>(amu41tuAt+uKVLP4JZqz)N3EUPZI>OXvx|1`dtGCdY^;_qaSFL2*!}6^ zXVb#g7hgudT;TLx9x;)e9wEzIX`Fr&Ed%y%h zi*lugp99bYMCvW%3H6<3N}SwCv{^zD{dek#)eWsiT3I7$Jd2}rs_Fh&WddsWPt&*F zG;cVm)ih2^<)?aumU-LWBGFVrdZ(;p;HwTj``amyt~oo^ftEaP1nlU|XqOC<>NyXG zbEZZIL+dT2n{ZiGGO>s09irCMto|KO;i0emcwaENx%amFZovX&?x~ARr;>o&t&9iomn0vS_P%^AF< zYG6TALNdSMKJ&4=pk&QGhWV3PD?fwAc7i`LYI9GZ`D60&vOeB>$$k5s3mi=z@(tt= zDj&+tdm;9Zzi$`V>GHE+fC017F-5AZMx`_VzHNVTlkDHCYhjtC<;?djXC*t&Qew&z zcclP;3-6jp1iP7ImwkMOZ=%3pic&;&-tRXF$Ef-){hj0xZy*2__cST^ZJU%yth+W8 z5xbn?aQT008enJP?XGwIhF?ek3mGj|D_&TLfD=0fFJdA-2P;KzCFWfxw(IFtj+-<; z7r&;R(xFV|DQSY0ioD)QOJFGyPCNK8U8|=1On`T@z=FoDsE%XOby+z`8WmgF>n=|}yYUT3 zM&@k3mw)S?8W*d#ez&S3T3X@kVm)3buIM5S-zoBc5370odqhL~cU<1*Ant^F8zQ_f z1L<|8`0l*b`xHVV+qTOL71OW`was_YTJ?;lyd38{H7mT@yJ6klUvP02e!hLwZKp-f z@p-*3H{x+30E!<1qL&TtlYcaMoTh)qW5)Jp8$Ej|eWM*9*&OoPXxx|DvQ6gBUFtP{ zoVGeV37x%kB5afrL=8AgH|#^W>OSt;BNcIzr3CnvoU26FCq+3}89 z40nRS0yqK$PA&9KR0{f@p0a5G^p{^aXcOuyP`~=K$dDy;NcO(NH`SeQCkH-%0m5yO zl{**mbz1eyVZbu4v`fr4DrMrehQ3F}n|y7)hTJ-%;XKi`rSTp&KJrcQyj;{PHM#0{ zQL?>Z*(m@+0KhgzG(Q(O6oS0{{LgXMC;I{E1zvG&8*ucrLRCprAy^>88-*$ zEv;EY9ZyD_2%Dj1c)f1@%I&scsiRZ~i>y9h&G!i$00g2f&9%Sebrrl*Udt#QT;>BW z3-Gm+#U%aGLD)g;t325|IvnzU+1U*)mxH#)@|t8D}iNDO(v5J4WH-lS)GN{q~+-hWF&-O9AJcD0{x1 zd_)i~;#}Ci{7F3e73gg7Ae(6qAga=&KL3~nPcnaI89F}R7~`+l%2S*a+HV#L!=~#J z50;Edh;wT{>&qV@^z#hY7i03j?m7K)5t$O`-~cm4grQ?Lw)^dY&431!zyLbod!k&77Y2KEZgyyNpBoz+Xx;xRBiBM@|z>OM6S*1aDM zdjh`W2@f5=c;?m8eENVuh?o4!Qq>-3e+d9YMeW0V<6Yxf4`%su0G})2)c4(5@t_|ls+T*6 z#0XOI8?!o`?q-%BK{-QQG&0v$Fuv3mC$BEMzoXFdHe4#-_KoU&Fqr5f@?$S6S5qC4 z^jKZwyqS~guc+)AwdXXg7uN&6&J52I&1S*_3zDsaccyftrE0fca_shiA|N?eQVkdT z6^iL@pDxSu?lP3s{Yj`e))?y9J)SR}p#xx&b;RuZ0Z2-uDa0e3 z`_^VikAEs>0mr>3p>jOuQq_G0-)H~_vE6f}m72)Z+ylI4?9GBmy1sJ2z}L+xeEhxP zIk=oZxK9ty%rUFlKFUr4fqy1K3X`1;ecJuM@Psd@8>yfwmf1EN4orc_ja!u&RyQU`rfJ8LE zWM!UmAR>F9D2hMGvk69pDA1)E6r)0vXi|tG0YZ?FkdTm-`du&S=sFVfR%>Ja)Ui+y zBu4h25Z!u2o#&Zzli4(Vk8PR4Vf^3eV_w^?{U5!|U61m%~3`+5DL-o+($@iJ$T*t@`pfsJhGJx%UR zit+~DWcqZ}+gEP2tGmnXY2%p9ZP5^O0MIdy%Hd?`YP)xd^ktM)^86z!Z5*2Qn3-xd zEfR!&eIhVLJAGd6a|x_1M(1dkHxCq+3MbHWPmb1_KHhzq{pjaneF>ccdKc-39Ir^Wd_r~kYjZZ+UD#6q=xZ47%f0aaF7Fm> zjGFwe0VM46AU=|)9?J)WT^fF`eX1T)#AOqpJm$AfEhcyT%>wD$jX zTn7>vB}y)1R&sysVzbM?(7k2n;B2N^IvX~ZF`iIteV482f0wV;)0_50A#>VC|?_ezhl-7)q+5AD(#`F9*vG%`W!jc08p zA+O$k2P5Bgml6^(3>nw%r;{bMMN6D|=0)x!kS1?EC)qM~Ldn;0^wb#PoS<`~AWuLF zy!F%!h?f; z%pXguVM53XsDf?F+k6+|6m(_cU=#ETIFuD>^@)t-t}_y_(nlSHRBkb)eZXU!_Fd)@OP+u z7A9lUi@(OH_-m^p(fl!x`HiZv9>D>|13-HfgX1PH8WwjmA?gEv929Jx0i^s01|9?!77{n2lnxsYGxiWd$W z>td(Tzz_g{ElsB9ai*$~>kQhgN3!awP)qqX3u}Q%RWLM5;~G5C13fz<8%`1&I*IMg z8)5U7m9dL&jtBri6NCZ`iQXSIhf2K%OPV+Qlm^h;c}i$2a+TfNOmv0bp}vijwQBW% z^>=RIv))V>k_|vr;d}oq0DXWkbM?H5-9RGevVOZU_j_~aRr$0W6S^y~arMI6uUVR8 zmqZqsAHW0GTtoqWUyt3_hlHz@Ox1S(45aqd`i5V_4+YHiQzHiR6Tr)$JhGj>jUnrN z8KWT_yo=IH{U!7UH07R+p|QG%%#a{NM3`z*u)T8Gb=QYT5dZ;kfah0p=j%is`Axn% zmbdWC@vd@65fK1FwkboU6h4@G3&iwGv zogXrN^?mHr`ing`Ehcl`^rJ&-pMwAhl{c;2C0o7N`FC2lD^Y^2oJ@iMQ2$`7UUm~Z zLj!N;5|51%te^P)2&3;#RZhrmc`+@)Pm{PpeRRf%1VLBy_oHupcGz>>uplw4Kaz$v zU-0Q#z)NN!y7-=*15EZLJCr%?!#>8YB!AmO*W(!}F7`)?X_~Xl4NOC&6|DIpel@d; zls-@Z6ptB0cO6-7UI`G`oAYZ)j>wdrjS=C=E2MqVr>cUh&B!uEW8vrw~KldA-{ z0EsQf)y>Z!PU(6ncKt4Y`f`?7_q^#{@jL*C2utst*M+XcjI=l`fCHHh8IAfEzgy7k zKlg`iwBN)tpP?qxs@go>JJ5*<^I4N*%Ax=w7!8YZxOY775we%N5;hO+N zUPufiIdlV=J0|$T9uC3Q``H!=M6>Dd4$7;qR^hY&5FuwnMMaG&+O9eCDYtH(C=f(I z0m-R=@zFU#QG4$f`!|(z#0j+e)59jzrPng)laV(kZ}LS{=BdrKfB>~Log$IpM*l+K z;r~C2y;RpeNbI+wBxv+Zo24Ha9M!8Kq`l1#$pt1DLIza-fB`N`y7UZv*Lfi4;3=yz z>#fJ`RUM8x-q{ABF9M|Be>MREc}DY6J8v-@ns34-vhw>jr{J}a2oVZ2-1^4dn<*7E z*_nH_QX(Q_)4m_&Lo&(FAI%vz_V(u=>DIvzcbKZ$WLWK%2!RCQ;b=rl%=AwP5fGl= z+TwA$71G?5H)ty)<;n&q6Ve0$Ab>!CX?#J-O5s%z5ke|NQC2MIv+jKi>+Bv|VIs2= z>eByBK0WUA01*c7^DIBNC;$gNsT$2eS|SNjC0jnq$IB8^*8Touqb6b+i2Ib-CV>!= zTm%S+h^xdkA-dm4*2-a`5dsDL@fL3)IqVS;5B+@w4e?Q=g;MBj*V&_7cnhBOw!aLt^4p)p%puyl zN7mdjud(iK?Xvtx&_IA8cbC#-L)EoosCtm+GT1$N>ojhe^G1=RLxpt*cnEjL{ubApNHti|9U z|Ed(AP+etu+WqMO0Jd7!E#O}{n`h0>bW!<}jhO={ibN!v*zmJkW+c61apYfn=ewMJ zPvZMzff6ua^~G*7zTOv^-inon03u+TtvkBxd=~H#CgZid z$j8Lis{}yEjGIaRple=UVLS;YJ;=^%-`h_$JdR(i^#Q71=!rWcmstt9tk3dR#QG#poGkF{Iz944&Y14d~-+V?XHsMAuLLi6968$1p@hLUze&-xHlc;EjBZ z`{7#Y9p7SMgw+wx&6BG#WeNB)mao=MJC;rPf0Y+wwswO(ZCYY)9C_uOsu#~~gz=G^g3nV9R&|HF4h0;~}NFOxtdi*FOR*SRa= zBt@bm!1^0@!_QpWH%pmux%9o}Tdl>_+>aDPsh@RIuL3#N6F+NQ9^3;y%??J@(mVbYFvG{r% zzx}|z@TG1emA~}3lnwvb{C8a4dLPfpHvn)k-Xx@R}dzOuY$pibpR^0BE>e|esBiw-MvImS+YdA*Ja zgfqWUR6{r>)yAY52#|qG{`!Cb)p;x^(Xk>si_!?P`srC%Raljt9TJXdQ5S`4==3a6 zwUAQrK<8S=u@*Oxe_F2JJc+xfPMbpBQs}(r;D~_~oxBa{$MkrO#t!Kh$y&}@%nPK^pa^= z_TA2IKYEWdz0GO1Uf<(A3(NHPsq+E+)Oyc;8$7$OIsgO+hi6prAl7$TskycilKTV8 z?bn(jZ@hlh%;xY{?`CH7`lm=gpa2bbFVdA+yp zcbx3cOKCqJS6Ics6or=yA^et67`yrg$n5-3kIL6luwMLSv;QsT{bHNnzjux0B6PAS za=Kb*9e6@Tq3Fl)?D`rXES*(D*UmBrrky27if=O9Z=M<2P(M(8JC>LZR@dE$@$(w9 z|1a9zCyzE;sbb+3u6gXr&f7t%d?nTyQ*5>N}{85}=D3z6IK$>CC2@>>i zslwmCZS}>Ho4$_D%Z-J=AP9?jP2NI72-82WAF>FF5=%Df`}#Wiyt7!X{(p=4#%yVw zasQubs(p_Nx^&i~wzRs!p0lwx2ONcH>&}`XVKVLaWY&bu18S`8b_IO8p6DXrG_4)Z z4>ZqWm@#={694p0_U6wn_Kd1whAkWP8(k!lg2Vqbfv9}*wCL#2gdO5)wdY2T!(^jn z!b>?g;@`*6IIR;gDMfG|hGTD6yNH?s`!B%l5wV{4uJ^d<(DSF=Yc?4KT@8B_on$Aj zEn>^XxO0`CXU#`Uw^TQ1aF#4rTl3s0&ZcJiXEdM1pYV0%Bh-z)9f+_t<2AdIeuqLl z#!f=>F`DK}^sFIOV0K?a@GY@d_y1m11#r%K5ITA!Dt0XMM$!t^!6y~q*3zS$y{}89 zv*GBX6yM%g&e8j5uNLXi5nSXodI!Y4&!A#@%UWP&9@QBw9ipA&7coEW7)T8u{>da%VSrMO)UOOW7Mg)XLBM zY_8N1m^A9!FQ{{*%kC zE;Swv#@$E$OMZ|e>3xjjBbB}PpSOufcgdoxw0^XOqUcxSa~Cpo-mE@0mr-TUT8-n% zt$!$nAhqHKw#ILT8WjNocHBgEqH z(X#IT-Uhg_{IpL7X0G)}ZGOPU94Ux}u1lP2%iuifX`h6K*^JCMWLP>rk9w*Uc| zWTlmb)az6BKmchh{s65zE@pT4<&*~>L%M;zVT{0l1OYOA7du*X!`hq+K}4o#f4|Gz zn-3L<5CDKBwngJa9`t@pR?b;M(hKM1xSM0GK2JvsaJ8wJQLV5PpAGn_0k4jk#>;Bf zV0e?L8p=o%^=$-8Gxk|yk;bP;MJrL)rjJs$m+I0`APii=xG60984&omcl+A0=O&xIed(W$3+K&{S=vJh2>0#_06Eo(o@mW#qRmrqZ9| zQ}7Tyhw~VGT~}Vw;t1d()I1!U&dOV8IJ8%(|KzC7R`o-lYKVTl-7`O_G$E_&n`B`U z3)-_y2v-tTfCWeZeXdY
    o44~ajrbf`f91J$Q@m;nGfSKM%AnE(Nere^idE2-Fv za=ai!1{;FPu7|a5g^68#t@K_OSKh#D{10>7Xt|x^TE+xO)BWs!mac6S7E<-EWEHM@Ko->tMVM$PsyZ}{(Go(N9X z$f=`_#7`5-mNUKRiQfObk%LCBqO=@Y&=${OC26d2ASS|RmA7WIRdwBGeEX@^n^$S=%X+#RSnUcC?`0#bZ+VyL zcXF&pG?ITPyX>J>&mMA)H=?ojHzq-moiszu+DD17X&-MNXBp`V zoh@3U9-{M}q67z1U-eg81fp{|;;KX|tTPf%=MOp3kNj9Z&xL zUuPfQlQaMVV)n_;3H_FGK`K>)@Z7L^DAcwg&e(V)>!}Ak0w4itH4}-ZU0gEVrLUIH zC7><0D%=7DMR()Psf2r{VE>?gX!;Si!nA}otUoE5n!rP7?%o(B&i`qcP2~D(hF45< z9n$ybyVZzRIlzwGiUnHU40XeVOMaogs*|hF>r-9mBq`6oT>TP99Y)C(jOF${+3%3e zwB*KYfFdHy>1EE|erY{ve5X3uF5LrYj5Lh8un{9QT0T3cYu}qfmc|8h))a{cBp1Fq zol&GPcnlMMNw-)7FuF+9`9GjjlcLODssI3HL$LNxeD)o}AVTk6;s5|HpBey&felhi zYbNfLh=3lSUDik6GVe*-S_Npf9cFH@XxGZTl_%^xf@nmx{r7MW@?m% zt4xD_Jb@wr5CzMA`MrMj=vk8vJ}-mXJMw}0Kb&I7v#viRRKe2@QNxa~r%Y&^WwuFw zJnXzGH&S)^wD!!su1-Qu_08LXjL@Qh>wH&gx)*ab0icH5TKOQvNvM~YjL8uY-9&H& zXe$heQ?THWofGspNr`D=s zUV?Czb663;fCK?yjLPzpT4%5TKze`G3Jm5-gFAPdZay3qlLUAA)xG(%9^^$cAem~O zI&EP8V4TR;B0zx`g+%UI%0}!bj@ST*fFN&6Ni_ek&OPkoSa>MwEMNQMOCXK?j)1KM zL;*LUa^~?K{i}fszh^g{LwrHIU((R_*SYO|#@Az*m7npq3}5Rc{xyYX+tHe%v->XX zG>R|Ns!B$CL?R5fPrF6ng7o{wCo}7gd3;fO(LS85KCe!2I!96@S$$qzJ?VkdoY3>Y z0DvMQDlicwbz0H#_x>6MU;q;^XYHSzW~YhB&SZ!QnnC~r5&_T%h0g$XV7Qiv*!Mc# zbB#obwVV&pkH?}!VSJ3%f2+lKaEUgJi<$5_EKNB;1XyuU7Fw*=)ipoxY!D&}&o`W! zKSJX9RDeLfE(_`ut3VMBp3g1Yof|KsnmXmRn(Zya)}bhxpM6`KzaU(cw*O7XE9n<# zt$|EGmc2V|R?BP5z@TA{`>Vw{zc8ZtzH^|Iw3(y2f^pZa^4(}`P|V*mJO@s6N4NC$ z=E1I!t}%XMtJUiM?LIq}Tj)flKfT|VO&>e;gmj(vRm0UM!{>zR1sV;5rqisBdVL0I zqA>f9JmPt>t$$-=y|YU@WzDMj9B0f@K>L6IM`PtW=n30Q@@4?t`m#&EKfnIlnv_N$ zL7oZwV#dtY?@u}}Hr`8AL^HsYVLro9H_dNMi?*D~TbH;Vv3@PoneiVh{&tUCS)SU8 zf0t{uXnn>4AV2mE>m#}J%kVGt9VGTlXi%+oVD1?vx5ia22C*fHNmXARtbiazIDbHm@>lz)Tp_=AAb4Na+xKUU=9UFco8oP`!ik?> zTg|f7j104sg0Gci-&H?4YdGT)d%K42B|qHldHv?_oXxP}W#kaTn$db&<>pM|y45_K znV6X9D>5b?{oG#GrJR}JYm;JR*Ap&=l@?vTiPu#z{0{>>|7x0R;pzYdxTq-T5Jy?& zl>dKy_uINayoYo&a{+?dT#O0a_UlZ5}`_8uu|o9m)_^Y3--`q7Te zc7YSYoDT@gcDNT+lzePX>sy?RW_P*SDMG&|&|nJi=jx~3UZ|knOE(oC3fpjweY@bo z=lJbl7rA)XBWNStv&(DS%>&x1*G@^!m(H}>NonruC?Ej8*n3yrwCgB?I4 z%u-EkUr%+BL6g0bIldPCm4LCA#^fyTp8vV2=03(cakOs?HyaQE2!$A62#95{zybq* z17(kegEXxakWO%&J0_HS*v?a;*K^pNwojbL1?_}WeajwKP2YUl2oVqjFmd!BbSb&+ zX6hT);GeK^mpS8|DJsQg{-1k18Vna4+4y5e(eCI@A5T1t#3i0GyXwK-3Sx`C%n=X- zK!6}^Ig8^^mU0>WgzBI~-31rSSH`SlLgjrP5uSDQLhEt)HMs8P|6I6`t!d|?m2I@F zQ<;@kFH?+E7xy>EThe-6sqZuu(mXNRSstxEI&O*iYjzOrt-?XKeMOBlf1mH_@wfG@ z5vejRXJDPO8qXFI5NAEN&4^JRV|oiecJ%&gMD!!xU`Ks7)bM=YOGNu~JlvCt;^IBO z+10C?AYr&(avpM~CJ7}I!sCIRt;6FV)b?=e*1h>+qinWklYS{=>S6jiSa!t6POLIYh}0 z%yHm>Iez(`%6E7#D{A9Q@=q#rckJQM`F(oM#PoXAS;mK z8zv8OE$uQhkIoWqviy&+wH=jCrk_Bik6Rg#!j+z2155dM@Mof>`#7k*sj5mW!I{)0 zyvT(@*lm2enD$vcYZ3d0@mAhnqP3c7l#RIg>F1(eDSQhsX!wLgL;(>IK+Ekk-xE7Y zQv?bH^v`MO3NcHi^+A7PPnmzP!j$akcRh&{PS&P$_P7ntUwA3$rj0J<`SuSlBc;vA z8eHLXX4RSxINSDq7hWvPWky=7Gw%Wl`uUQ^s&;d}N^bTYzj57h`slKJhc^f%+-*C| zdy}$MJ9L0WpFs6I{q``TZmk8PPD>Ogi)-sB%iX{D&X|Wp@vz#gX zoW8S34;z-gJ=6mlXJwL@%-2+@?W>=rakZnH?7X5R!iEi6`l%k#{4q1K!{-fCvgT4=)<0ewjy`9bVg>Qm{siGjzpiK(OC28Qka1 z_t&4K`bZvK2Cp1+=AXc?aEhY;~<@Ra(gu0D}=y_!u_$>9JjSgU=vQks4ReT#S(FF5 z(<(UG#{=8gtv8t5%&<4B{Xd@k|qF0p~51~!(iT%}r87A*!ljD>ewvuseR zI~vR{3z`c`Lo&wg+fSrkUEbjt_B-Br;Iug0%;~$jicfXDleI!`*0nL7yG1ATAs0nI zR_|7^sp7bIXF8}oFGlTKZxFR*&C^S88@H`qyvr2ft4jur^!qeix8JXpR^r^*)0^f8 z?Cq4NineWWN+0CgVXO@GETwimwex~*;_^nz5Y-gH>E*v8H?8~a8@4NZjv~|1tPJm) zXKAwDJ;=KtZ?{_-68i!v2;1Koq|A$D&E_tL-EyJuk#6sCqUo1-8XRNkD!-Dm_rC+( z@9d3cIN@?4n0Gpr8m+}XTHo0m+z;O+U%&t$H$Vad@Bkwz^_rKlt^2ti;hEg=H_p#3 z%FQj~*rce~zvKG&J>J6Wvx@J zqSP>9$OqXEQ{Qw}*VkA7^Ar`5Wi^^DfXv_wDsTV)|NsC0|NsC0|NsC0|NsC0|NsAg z|9}7g|G)qL|NsB~0j~M&w$BUiWo47+E%nm(ZmxEgdoOy!p0%tLpaWU~Kqoz|<;&R$ zK~Hul0*WH$%DU;uq@37f1SFK2o~9^mOl2~jrfO)=%%di1XvQh%)HKrs(dv01WHVFL z4^zs1spSJ|W~22c$*A=jG|eD3lOWoF0D6y5Xwyv8*-ugGJwqURhSFpWH1vV8f>8}k zoq04=@%zW`Z1!b_u|$L{V;}pLni(VOFxKoLA=yQCV=T#*gzSxML$Z@4qJ@y1td(Tn zQb;QGoA3Ac$M5%?d(OR|=dS0Tdq3wqpU?BW-XIVo1)5)AR$0dB<#Tz;g`%hoLKBpg z4~sK;7}Ho;0oqV5;8{$9z9J6950JdvoW0OcS~kqogCop zz*Tt+0LFkZWq=jnql*(KDFU9J0EhxA#Q-c*`ecGc*%&`jo2E%g85`5qnWEi6nU&U1 zcB5V3zo!C6000pJXg)wB3-38K815Be;Z@l@b!ZV@nV4_kb9t(Q&kuJius9;H80Yb^PivpoYmuNH9+?_!DVg2PH#Ml3a{HKdV+%4hpYJ)o zSwcOv(79YNHA!U1A{kVqfwje@q6x;8`T3Q<@+-!*^{SS0CoL4JqqDSye-&6BDe?*4 zGj})GOy*-pO-`CoAoCf(+p+J(B_%D&>_k2~hOxKv442Hc^{A>8ZB`4hE_pj-$tDZsM;}i?Y(yHBamOTlJePY`#T;VbdCkRDwSB})bpQB%vvn@LB zJUMRwE^Oy512yw|PS$H7Y)U1=lIL)5d8ga}i5Io8E(|y=-c^_a3#V*Cg zTbh6Pr{=VxCOpu6_Z{go+$4v={FCi^w+g9llz?vVRl*KHJ?q;Cb(va{UQX~C$Q>F& z7pxp#5*PbHFChv#ed;#v36n7er`Nl`2WlK0rfWlvof2wi^ww4a{U)3C~ z4y4r9tn|AJ-Wi>2BSqwTC$qp#W>0jn$&7nebmt_$(-RN(Q3Jv&lPYy-D+@$0xcSE< zWT9%v;+U;a6SI*BFUR6Yt!GBqfleJIUPN=n8(*?jr_V?F#%{o+jWA`f#^dcSp9mz8lxMMs5r zN4caGs?18&QBtNn(Ee6qyUa5!8=}Wm*p*&jm7HY{T!A`xwGD{s8}HnbqCZ<=SyroD z-lT{?a7j&@+l2i#aG0<55=L-~yNhHb7_@MEJv5)lHEX>Rj5d6$LawKGmq!y_Hj~%} zrD1f4Eg_k4{YTa{8HlHsxg7nanZr@&lBiK8mZVOK5WY@WAXxfsteH?ZO_hTp|EGuT zhrAw=AeqnCXZ-OQ9t?hQQS`FJ8MWiV1CauQagSD?mbtxOP8>aF-UOBXCO&1pYoHJl z{+pnCLpcxMs511FiHe+QVf{N{E6J?B|K87Yd78&Q zXV#iqw*PZczWYtWMz($ML&xYw5PjUK9HhuRxcsJre8X$$3V7lAO}oMmd*8Yo5Q8DK zLRr=coicm7S(muNHTOLcAX9O__V~|ZKw|%e$M5VR`6V9zMeF4^p1ItS&bF~0e^KM5 zm(v8Sou-H$#|$}&XGg11isR$fD@L_@9S+H+C}IgNfd!78J^Izz6ILF~upU%cF9~d3 zSXd$Z`0x4(tYA2uy26jP|C*c}cV9KZK%d^Uo6gH0l4%^0X}Y#IPEgt;9$HmTKdY>I zg0qgw`d3H)r^}DwfC_fuas}zepeNgZcaJAT zq*`3UJ=Yos6?cx4Bn;8Ij3ebVuz(PgDc2dw$3B+gQn^Ay4Q3bCPE5dkq4rjGFOd4&8jkU8l111s{&BKxf7q?Ii}GD0+geVs$8_v z4aS9>CLKQib~~TY8UyEe<%NZ-*F^cggnpd3RK|cJvm1#>I`VdC!}?Ob_{NsEqBqU5 zxrUS36$I-4JTiSA^0!3yLM-TIoqbcUrX^_ej~KRfOSqsiB9@7KYWos*UR`e3$ zFKWyTRW&~>f%l>2?jQZ?i%nndKSy7^zT18NHsmvo7jZsINzwUUXFVwMDm>`KJm}_o zfF*CVG!axu#j8JSc+liQMA<&`oTCq9$+rmChbo*Z4?@|kuX)2T7^{QPAq z1_LL?ZS9-=-9enE)smIp1Ppq6O$L_?sJMWR2U*$98|DEuo0>A^l7`j@PN{|~VR8;2 z&fJY=G;7!3QT@L|EPP*+G-Q1*y{#lBdy4Teehsq&Yk%tdbY^!?@3uz}!2GHlPq+Oy zc&E5tR=R}hI`%>~O}g?$c@a-9l79$a6q=btq{|W&aS852Zkul+olARRzLVWJk0nDX zY1Q4=#?!X;#*IMqOo|+zq&gCv`p0 zvpIip@QYy~kJ(Q!3U$6bj^HBpFBLKI7@U>dT#t?=GW`}G+=!r>3;LyWI>kOJ(w_b? zQxf1I+95Xdxho-Nhs4WVgJ0T8x$;BC_}xk!$BmYP3%#NX1^o6E-Of23Gu>oxHc-dU zbbr=(TMp&)-v8NH0-2@ZgUdHJ_H|3Wz7e^IBw4-7vxWp3yr{%xc$69dd~sA8nPSG* zWIg0$9#Q(%YbP!+h2fML((e?T{9qdff^(*>jF;7H$ZMRigrjch6f+1-J44ytv`Fykiw|ezlNvjn%6<{>EW5d;qnvXh`lx6;x%4sEmh$ zEPaxYA}@^uGlol*R*#pi655D?ufV73jtcjYI3{5{7?NcfsqPcGbeI|xx3{bT#~VOd zvFWd>v3^}Aqu&1rA0A*_BI{(%V!CEG(_fDg#sRH(Vun6~i%~R{3(8nJ>$TxIiSXD> z|F9OzIlut+zC%;@ne}L@sompIeEPfbvz3`HUY9eO*yQH90wPA;(Y?Xo>i4;OLRH;i zsi4_LY3WlGdV)k))M7W^xNwF;MFwG1E|f2}`rWocM%~epfqcIxr#2Sz%2_}t<)CKZ z{zPe3`Q|`Jy?E%4Ge59qFWosVOrhjV4$6 zb%tO%p7u0`O5(5a+MH?Dko+0DRVxGDKI?dAE3-wHH%QfNwT4(j?^%V(d(8E2v7*XK zRo5eSvgGk`MbBCEMYlhczBar^+Wxktq`)FT|HV4o2s$!8OvJm&PqSz)KPWsXPT6hN z;KE(7VECAx2@$bgO)EzkjOcl%kFnOOD!mpX3t2JZ{0j;@Ifj!;zteb{npH>ySEf>S zlXZiIAuj>7c%g{T@FABSpT*^@<^-4aWF8)sa06ahZIH~@G%3SiLl;}ZbP3TSabs1w zHnnBv+jN*sZSH0QSKtOmwWR)pwY9%P4uO?m)a2m`!jr*15Q_@4G>}wIkV)x%^ixaQkIen!fsj4ATygT(=8TXP0L`O1ALy>Gp_Pk7&!zfHWSOROULep z{v+09yXN*G&F*ETj$xkh>o7q|Q=obvr3`5Sdh2w|vl-pZk7y1ta>l%{8h}Q`PPHGm zXTuA*vYmEzriAs=ErM|?2ySYQ8AC#;M+RjAqY@~b;IuW7Fep{=CRE|y|rbtPoqNPkB)ZN|tMtl7sYC7EZ zOxCK~G}_$z;o!aQ@dzM*Wy3TBa@<)o->D?hjQhbe}Fg+i4{rLSB|F`<@p%3@HPBLqXB!Cfo@s@6;7-P_gUjP$HDp(sFzW zVjzK4ll#?=;HeY%ce{M2KT!Qaq26!306 zKYZ8J@^R645%V@t3RrmgQ`9p$Y#a$%Up+0rJ=g@%x2*f0G_~wnT6BW`_!}b@f5Who zaKVMW^iQh`<+?T_gRPYU2?;c$nzQyhMv2?A6W|U6_Jnkx56P$3`CMx3bIlge!U1?R zOSotqw^Dm|AlfkPQ1+Q<8)Kvf>x(CU;wOIkr6$Yu&2@I4@i+1d=M?<( zNi(P7?`imjNtn@~`FA&_dG-``^@oX}U!IfMvlpI)Tr};XZ#%psSKwY`TT*^Y^OEUf z_xn7fIyYGd`-uTp%oGGgU1ae7djIt%;lAREZ^8)*pyKZbTPFLnVlD=Q5=r$Z}N}5$L5%JdSnW6XN-kB z6IvIY2yVD_Z*>wnL@BO(aO-@iEJnrsfEPS9DIx*LgFy<3L_rp-4G(%G%c{zl*E8Nt z^46}P9(-=NuIb7Bf!l&>@B4P7yRI+!qxEF=qrQ$214;t3TY2Yu;uUED+B(@$co|nftXf|{f@>N@AjtjKiqx#8udy& z?Z?GR+K@a32(W!cn*PCqP-tIPk2L!oqDY~bVBuaz!JkIqp@Vr+Sh2e1vyL9kJgxZf zr<|57u`U4*-~IiFUVY4!%+s+ih2M&dHnDWT)Q#K#mLU?iiM%`0!5#$dwnMY;<1h6R zlPN_92q>+uiqDiHF+{i1A`Vd^LTCq|N<^a=;{Vf324{pf<3VQ)*$SvB^mYPpSrBrb zWJAv&1#98-V@rkk9APS1)Im|C8nu@Al|YaKfnFC$y6QU&3vudEi)&|D8b5Kdvht6a zWee2ovF-g#UTYtHC;8#Gk<^aP3%wu8gb1w7-9T{9mLk)(`zUd46g$Y1FlqpfrKS4D zt`0SLn}nbri&5w?oc>Z$B{|7iQj$sXp`cF8JZu5{A=a-nRVf*D1Kce^$dJVW;TOeN z*kjDiS^W=D30Bu#GMR;Jfyao5C2VY!wIMJDlj3q_!#Lbbm1PZuykQxIl(KF8hAe;K zI?kt&F3~a$`AX{sEgm(RRSDFcy+ z?0uVU%W-{hL5Wrz7C*Q5JLbyUz4Wzz?7^Z!_rdt9MkhTRnCQy2@66O>EP(&C0=wM* z%NT@!9tKPCc49#vZJF5J+JLdI;_uQ^GMGhtzjIGDK7Pc?Sv6xQRUz6)p_O854x zbWyrmQQG7|yn(9s$1SpCHBunr>nj%Y(ZyKcW^HF}XS&dZvXJjyzsJp<3JN02XB^eK zzNJR<2}Q?qK9bNF2`bmT>Liq8o>eaS$-tQ$0R;$Lv`G2142p-I=j~H)|F4bC6eeK1 z(Sbh$OaeB0%RBKcvRpHGhVXe3Bt%egKr6ClTPSB9r44wpKF4@;{r%g;g}p!n+FX?U z@7IQm+v(9HDaOX6JeN{xn_T+|w16SLERW*`%uMjP|8(S;3|UT1nS-JY7)2kIB!O-s z2r3cI>%|<&H=<%j_p)gDogFKGzdO{etW>VX8 zs0Dd+9|gZ7-Rh1)OdIcEnex+^aD2!qrnH|D2VE_fGQ=@U{i3YB zU#leyIq=p}nwKO7hSi9YcM+|Y5(zJSw%vN9VOcJ)3t{*WvU( zwM@?*ol+$ID&hS6ZAoMi5zWt_-30o*xU8y$v%;WHB4E*u$-#Q2e-Ublq3zD^kIKf} zzU$JNE?e2YhJZ`j(Eks6SRDd~SqYyP&BS0i=so;$fUCl0rrj3gQ&CXGulb-4B)K_V z%`Z2vJyr|JuQy?}W4M0Y;b3AFB~eDL@ZoUaSmcb^Z6DV{8(6~X3YWg1ZO^E>yX57S zHm8Ls)@0ziB#UJGK5@&rd1rEHK1lD%%Y==4vmG%5*h}r*XqE@gxx!cI*y;&uvap;x<}$$|t?ncVIo z+|3{D=lqT1%@~nqGq@xRtyH^Bh`HaCa_Vcaa6ddf$AROwagzu2UuokxuA~bwTtI}= zt{GPIZzM5XP{NB^ZX<+}xDn;z%6KI~)%ac^8~s;tM-iV6ogXH9N@DWc(nQ&&4aoYV zPBKdQSg0Ii_Fjl>3FA+)epCU{c`17}Wo<)R2`9H+TqwWvS8gTQ LzeISQ+#>WpKr3c0 literal 86315 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

    gw{5>H|QZjhkwT+ketcB1i+--ZvE2n#Hmo_22# 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 El(B$3&j0`b diff --git a/tests/python/shared/assets/cvat_db/data.json b/tests/python/shared/assets/cvat_db/data.json index e19df46d4c64..c54411580c3a 100644 --- a/tests/python/shared/assets/cvat_db/data.json +++ b/tests/python/shared/assets/cvat_db/data.json @@ -1188,6 +1188,39 @@ "membership": 15 } }, +{ + "model": "engine.validationlayout", + "pk": 1, + "fields": { + "task_data": 21, + "mode": "gt", + "frames_per_job_count": null, + "frames": "0,1,2", + "disabled_frames": "[]" + } +}, +{ + "model": "engine.validationlayout", + "pk": 2, + "fields": { + "task_data": 22, + "mode": "gt", + "frames_per_job_count": null, + "frames": "4,5,7", + "disabled_frames": "[]" + } +}, +{ + "model": "engine.validationlayout", + "pk": 3, + "fields": { + "task_data": 28, + "mode": "gt_pool", + "frames_per_job_count": 3, + "frames": "23,24,25,26,27,28", + "disabled_frames": "[]" + } +}, { "model": "engine.data", "pk": 2, @@ -1606,6 +1639,25 @@ "deleted_frames": "[]" } }, +{ + "model": "engine.data", + "pk": 28, + "fields": { + "chunk_size": 3, + "size": 29, + "image_quality": 70, + "start_frame": 0, + "stop_frame": 28, + "frame_filter": "", + "compressed_chunk_type": "imageset", + "original_chunk_type": "imageset", + "storage_method": "cache", + "storage": "local", + "cloud_storage": null, + "sorting_method": "lexicographical", + "deleted_frames": "[]" + } +}, { "model": "engine.video", "pk": 1, @@ -1634,7 +1686,9 @@ "path": "118.png", "frame": 0, "width": 940, - "height": 805 + "height": 805, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1645,7 +1699,9 @@ "path": "119.png", "frame": 1, "width": 693, - "height": 357 + "height": 357, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1656,7 +1712,9 @@ "path": "120.png", "frame": 2, "width": 254, - "height": 301 + "height": 301, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1667,7 +1725,9 @@ "path": "121.png", "frame": 3, "width": 918, - "height": 334 + "height": 334, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1678,7 +1738,9 @@ "path": "122.png", "frame": 4, "width": 619, - "height": 115 + "height": 115, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1689,7 +1751,9 @@ "path": "123.png", "frame": 5, "width": 599, - "height": 738 + "height": 738, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1700,7 +1764,9 @@ "path": "124.png", "frame": 6, "width": 306, - "height": 355 + "height": 355, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1711,7 +1777,9 @@ "path": "125.png", "frame": 7, "width": 838, - "height": 507 + "height": 507, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1722,7 +1790,9 @@ "path": "126.png", "frame": 8, "width": 885, - "height": 211 + "height": 211, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1733,7 +1803,9 @@ "path": "127.png", "frame": 9, "width": 553, - "height": 522 + "height": 522, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1744,7 +1816,9 @@ "path": "128.png", "frame": 10, "width": 424, - "height": 826 + "height": 826, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1755,7 +1829,9 @@ "path": "129.png", "frame": 11, "width": 264, - "height": 984 + "height": 984, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1766,7 +1842,9 @@ "path": "130.png", "frame": 12, "width": 698, - "height": 387 + "height": 387, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1777,7 +1855,9 @@ "path": "131.png", "frame": 13, "width": 781, - "height": 901 + "height": 901, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1788,7 +1868,9 @@ "path": "132.png", "frame": 14, "width": 144, - "height": 149 + "height": 149, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1799,7 +1881,9 @@ "path": "133.png", "frame": 15, "width": 989, - "height": 131 + "height": 131, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1810,7 +1894,9 @@ "path": "134.png", "frame": 16, "width": 661, - "height": 328 + "height": 328, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1821,7 +1907,9 @@ "path": "135.png", "frame": 17, "width": 333, - "height": 811 + "height": 811, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1832,7 +1920,9 @@ "path": "136.png", "frame": 18, "width": 292, - "height": 497 + "height": 497, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1843,7 +1933,9 @@ "path": "137.png", "frame": 19, "width": 886, - "height": 238 + "height": 238, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1854,7 +1946,9 @@ "path": "138.png", "frame": 20, "width": 759, - "height": 179 + "height": 179, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1865,7 +1959,9 @@ "path": "139.png", "frame": 21, "width": 769, - "height": 746 + "height": 746, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1876,7 +1972,9 @@ "path": "140.png", "frame": 22, "width": 749, - "height": 833 + "height": 833, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1887,7 +1985,9 @@ "path": "test_pointcloud_pcd/pointcloud/000001.pcd", "frame": 0, "width": 100, - "height": 1 + "height": 1, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1898,7 +1998,9 @@ "path": "0.png", "frame": 0, "width": 827, - "height": 983 + "height": 983, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1909,7 +2011,9 @@ "path": "1.png", "frame": 1, "width": 467, - "height": 547 + "height": 547, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1920,7 +2024,9 @@ "path": "10.png", "frame": 2, "width": 598, - "height": 202 + "height": 202, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1931,7 +2037,9 @@ "path": "2.png", "frame": 3, "width": 449, - "height": 276 + "height": 276, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1942,7 +2050,9 @@ "path": "3.png", "frame": 4, "width": 170, - "height": 999 + "height": 999, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1953,7 +2063,9 @@ "path": "4.png", "frame": 5, "width": 473, - "height": 471 + "height": 471, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1964,7 +2076,9 @@ "path": "5.png", "frame": 6, "width": 607, - "height": 745 + "height": 745, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1975,7 +2089,9 @@ "path": "6.png", "frame": 7, "width": 853, - "height": 578 + "height": 578, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1986,7 +2102,9 @@ "path": "7.png", "frame": 8, "width": 823, - "height": 270 + "height": 270, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -1997,7 +2115,9 @@ "path": "8.png", "frame": 9, "width": 545, - "height": 179 + "height": 179, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2008,7 +2128,9 @@ "path": "9.png", "frame": 10, "width": 827, - "height": 932 + "height": 932, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2019,7 +2141,9 @@ "path": "0.png", "frame": 0, "width": 836, - "height": 636 + "height": 636, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2030,7 +2154,9 @@ "path": "1.png", "frame": 1, "width": 396, - "height": 350 + "height": 350, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2041,7 +2167,9 @@ "path": "10.png", "frame": 2, "width": 177, - "height": 862 + "height": 862, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2052,7 +2180,9 @@ "path": "11.png", "frame": 3, "width": 318, - "height": 925 + "height": 925, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2063,7 +2193,9 @@ "path": "12.png", "frame": 4, "width": 734, - "height": 832 + "height": 832, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2074,7 +2206,9 @@ "path": "13.png", "frame": 5, "width": 925, - "height": 934 + "height": 934, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2085,7 +2219,9 @@ "path": "14.png", "frame": 6, "width": 851, - "height": 270 + "height": 270, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2096,7 +2232,9 @@ "path": "2.png", "frame": 7, "width": 776, - "height": 610 + "height": 610, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2107,7 +2245,9 @@ "path": "3.png", "frame": 8, "width": 293, - "height": 265 + "height": 265, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2118,7 +2258,9 @@ "path": "4.png", "frame": 9, "width": 333, - "height": 805 + "height": 805, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2129,7 +2271,9 @@ "path": "6.png", "frame": 10, "width": 403, - "height": 478 + "height": 478, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2140,7 +2284,9 @@ "path": "7.png", "frame": 11, "width": 585, - "height": 721 + "height": 721, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2151,7 +2297,9 @@ "path": "8.png", "frame": 12, "width": 639, - "height": 570 + "height": 570, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2162,7 +2310,9 @@ "path": "9.png", "frame": 13, "width": 894, - "height": 278 + "height": 278, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2173,7 +2323,9 @@ "path": "52.png", "frame": 0, "width": 220, - "height": 596 + "height": 596, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2184,7 +2336,9 @@ "path": "53.png", "frame": 1, "width": 749, - "height": 967 + "height": 967, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2195,7 +2349,9 @@ "path": "54.png", "frame": 2, "width": 961, - "height": 670 + "height": 670, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2206,7 +2362,9 @@ "path": "55.png", "frame": 3, "width": 393, - "height": 736 + "height": 736, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2217,7 +2375,9 @@ "path": "56.png", "frame": 4, "width": 650, - "height": 140 + "height": 140, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2228,7 +2388,9 @@ "path": "57.png", "frame": 5, "width": 199, - "height": 710 + "height": 710, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2239,7 +2401,9 @@ "path": "58.png", "frame": 6, "width": 948, - "height": 659 + "height": 659, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2250,7 +2414,9 @@ "path": "59.png", "frame": 7, "width": 837, - "height": 367 + "height": 367, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2261,7 +2427,9 @@ "path": "60.png", "frame": 8, "width": 257, - "height": 265 + "height": 265, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2272,7 +2440,9 @@ "path": "61.png", "frame": 9, "width": 104, - "height": 811 + "height": 811, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2283,7 +2453,9 @@ "path": "62.png", "frame": 10, "width": 665, - "height": 512 + "height": 512, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2294,7 +2466,9 @@ "path": "63.png", "frame": 11, "width": 234, - "height": 975 + "height": 975, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2305,7 +2479,9 @@ "path": "64.png", "frame": 12, "width": 809, - "height": 350 + "height": 350, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2316,7 +2492,9 @@ "path": "65.png", "frame": 13, "width": 359, - "height": 943 + "height": 943, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2327,7 +2505,9 @@ "path": "66.png", "frame": 14, "width": 782, - "height": 383 + "height": 383, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2338,7 +2518,9 @@ "path": "67.png", "frame": 15, "width": 571, - "height": 945 + "height": 945, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2349,7 +2531,9 @@ "path": "68.png", "frame": 16, "width": 414, - "height": 212 + "height": 212, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2360,7 +2544,9 @@ "path": "69.png", "frame": 17, "width": 680, - "height": 583 + "height": 583, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2371,7 +2557,9 @@ "path": "70.png", "frame": 18, "width": 779, - "height": 877 + "height": 877, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2382,7 +2570,9 @@ "path": "71.png", "frame": 19, "width": 411, - "height": 672 + "height": 672, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2393,7 +2583,9 @@ "path": "30.png", "frame": 0, "width": 810, - "height": 399 + "height": 399, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2404,7 +2596,9 @@ "path": "31.png", "frame": 1, "width": 916, - "height": 158 + "height": 158, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2415,7 +2609,9 @@ "path": "32.png", "frame": 2, "width": 936, - "height": 182 + "height": 182, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2426,7 +2622,9 @@ "path": "33.png", "frame": 3, "width": 783, - "height": 433 + "height": 433, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2437,7 +2635,9 @@ "path": "34.png", "frame": 4, "width": 231, - "height": 121 + "height": 121, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2448,7 +2648,9 @@ "path": "35.png", "frame": 5, "width": 721, - "height": 705 + "height": 705, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2459,7 +2661,9 @@ "path": "36.png", "frame": 6, "width": 631, - "height": 225 + "height": 225, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2470,7 +2674,9 @@ "path": "37.png", "frame": 7, "width": 540, - "height": 167 + "height": 167, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2481,7 +2687,9 @@ "path": "38.png", "frame": 8, "width": 203, - "height": 211 + "height": 211, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2492,7 +2700,9 @@ "path": "39.png", "frame": 9, "width": 677, - "height": 144 + "height": 144, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2503,7 +2713,9 @@ "path": "40.png", "frame": 10, "width": 697, - "height": 954 + "height": 954, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2514,7 +2726,9 @@ "path": "0.png", "frame": 0, "width": 974, - "height": 452 + "height": 452, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2525,7 +2739,9 @@ "path": "1.png", "frame": 1, "width": 783, - "height": 760 + "height": 760, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2536,7 +2752,9 @@ "path": "2.png", "frame": 2, "width": 528, - "height": 458 + "height": 458, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2547,7 +2765,9 @@ "path": "3.png", "frame": 3, "width": 520, - "height": 350 + "height": 350, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2558,7 +2778,9 @@ "path": "4.png", "frame": 4, "width": 569, - "height": 483 + "height": 483, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2569,7 +2791,9 @@ "path": "1.png", "frame": 0, "width": 783, - "height": 760 + "height": 760, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2580,7 +2804,9 @@ "path": "2.png", "frame": 1, "width": 528, - "height": 458 + "height": 458, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2591,7 +2817,9 @@ "path": "3.png", "frame": 2, "width": 520, - "height": 350 + "height": 350, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2602,7 +2830,9 @@ "path": "4.png", "frame": 3, "width": 569, - "height": 483 + "height": 483, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2613,7 +2843,9 @@ "path": "5.png", "frame": 4, "width": 514, - "height": 935 + "height": 935, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2624,7 +2856,9 @@ "path": "6.png", "frame": 5, "width": 502, - "height": 705 + "height": 705, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2635,7 +2869,9 @@ "path": "7.png", "frame": 6, "width": 541, - "height": 825 + "height": 825, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2646,7 +2882,9 @@ "path": "8.png", "frame": 7, "width": 883, - "height": 208 + "height": 208, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2657,7 +2895,9 @@ "path": "0.png", "frame": 0, "width": 974, - "height": 452 + "height": 452, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2668,7 +2908,9 @@ "path": "1.png", "frame": 1, "width": 783, - "height": 760 + "height": 760, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2679,7 +2921,9 @@ "path": "2.png", "frame": 2, "width": 528, - "height": 458 + "height": 458, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2690,7 +2934,9 @@ "path": "3.png", "frame": 3, "width": 520, - "height": 350 + "height": 350, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2701,7 +2947,9 @@ "path": "4.png", "frame": 4, "width": 569, - "height": 483 + "height": 483, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2712,7 +2960,9 @@ "path": "12.png", "frame": 0, "width": 607, - "height": 668 + "height": 668, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2723,7 +2973,9 @@ "path": "13.png", "frame": 1, "width": 483, - "height": 483 + "height": 483, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2734,7 +2986,9 @@ "path": "15.png", "frame": 0, "width": 982, - "height": 376 + "height": 376, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2745,7 +2999,9 @@ "path": "16.png", "frame": 1, "width": 565, - "height": 365 + "height": 365, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2756,7 +3012,9 @@ "path": "33.png", "frame": 0, "width": 339, - "height": 351 + "height": 351, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2767,7 +3025,9 @@ "path": "34.png", "frame": 1, "width": 944, - "height": 271 + "height": 271, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2778,7 +3038,9 @@ "path": "0.png", "frame": 0, "width": 865, - "height": 401 + "height": 401, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2789,7 +3051,9 @@ "path": "1.png", "frame": 1, "width": 912, - "height": 346 + "height": 346, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2800,7 +3064,9 @@ "path": "2.png", "frame": 2, "width": 681, - "height": 460 + "height": 460, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2811,7 +3077,9 @@ "path": "3.png", "frame": 3, "width": 844, - "height": 192 + "height": 192, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2822,7 +3090,9 @@ "path": "4.png", "frame": 4, "width": 462, - "height": 252 + "height": 252, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2833,7 +3103,9 @@ "path": "5.png", "frame": 5, "width": 191, - "height": 376 + "height": 376, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2844,7 +3116,9 @@ "path": "6.png", "frame": 6, "width": 333, - "height": 257 + "height": 257, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2855,7 +3129,9 @@ "path": "7.png", "frame": 7, "width": 474, - "height": 619 + "height": 619, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2866,7 +3142,9 @@ "path": "8.png", "frame": 8, "width": 809, - "height": 543 + "height": 543, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2877,7 +3155,9 @@ "path": "9.png", "frame": 9, "width": 993, - "height": 151 + "height": 151, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2888,7 +3168,9 @@ "path": "30.png", "frame": 0, "width": 810, - "height": 399 + "height": 399, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2899,7 +3181,9 @@ "path": "31.png", "frame": 1, "width": 916, - "height": 158 + "height": 158, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2910,7 +3194,9 @@ "path": "32.png", "frame": 2, "width": 936, - "height": 182 + "height": 182, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2921,7 +3207,9 @@ "path": "33.png", "frame": 3, "width": 783, - "height": 433 + "height": 433, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2932,7 +3220,9 @@ "path": "34.png", "frame": 4, "width": 231, - "height": 121 + "height": 121, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2943,7 +3233,9 @@ "path": "35.png", "frame": 5, "width": 721, - "height": 705 + "height": 705, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2954,7 +3246,9 @@ "path": "36.png", "frame": 6, "width": 631, - "height": 225 + "height": 225, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2965,7 +3259,9 @@ "path": "37.png", "frame": 7, "width": 540, - "height": 167 + "height": 167, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2976,7 +3272,9 @@ "path": "38.png", "frame": 8, "width": 203, - "height": 211 + "height": 211, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2987,7 +3285,9 @@ "path": "39.png", "frame": 9, "width": 677, - "height": 144 + "height": 144, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -2998,7 +3298,9 @@ "path": "40.png", "frame": 10, "width": 697, - "height": 954 + "height": 954, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3009,7 +3311,9 @@ "path": "0.png", "frame": 0, "width": 549, - "height": 360 + "height": 360, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3020,7 +3324,9 @@ "path": "1.png", "frame": 1, "width": 172, - "height": 230 + "height": 230, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3031,7 +3337,9 @@ "path": "10.png", "frame": 2, "width": 936, - "height": 820 + "height": 820, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3042,7 +3350,9 @@ "path": "2.png", "frame": 3, "width": 145, - "height": 735 + "height": 735, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3053,7 +3363,9 @@ "path": "3.png", "frame": 4, "width": 318, - "height": 729 + "height": 729, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3064,7 +3376,9 @@ "path": "4.png", "frame": 5, "width": 387, - "height": 168 + "height": 168, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3075,7 +3389,9 @@ "path": "5.png", "frame": 6, "width": 395, - "height": 401 + "height": 401, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3086,7 +3402,9 @@ "path": "6.png", "frame": 7, "width": 293, - "height": 443 + "height": 443, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3097,7 +3415,9 @@ "path": "7.png", "frame": 8, "width": 500, - "height": 276 + "height": 276, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3108,7 +3428,9 @@ "path": "8.png", "frame": 9, "width": 309, - "height": 162 + "height": 162, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3119,7 +3441,9 @@ "path": "9.png", "frame": 10, "width": 134, - "height": 452 + "height": 452, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3130,7 +3454,9 @@ "path": "img.png", "frame": 0, "width": 10, - "height": 10 + "height": 10, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3141,7 +3467,9 @@ "path": "img.png", "frame": 0, "width": 10, - "height": 10 + "height": 10, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3152,7 +3480,9 @@ "path": "1.png", "frame": 0, "width": 569, - "height": 483 + "height": 483, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3163,7 +3493,9 @@ "path": "1.png", "frame": 0, "width": 569, - "height": 483 + "height": 483, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -3174,7 +3506,386 @@ "path": "1.png", "frame": 0, "width": 569, - "height": 483 + "height": 483, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 494, + "fields": { + "data": 28, + "path": "image_1.png", + "frame": 0, + "width": 55, + "height": 36, + "is_placeholder": true, + "real_frame": 23 + } +}, +{ + "model": "engine.image", + "pk": 495, + "fields": { + "data": 28, + "path": "image_14.png", + "frame": 1, + "width": 43, + "height": 64, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 496, + "fields": { + "data": 28, + "path": "image_11.png", + "frame": 2, + "width": 91, + "height": 97, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 497, + "fields": { + "data": 28, + "path": "image_7.png", + "frame": 3, + "width": 76, + "height": 71, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 498, + "fields": { + "data": 28, + "path": "image_4.png", + "frame": 4, + "width": 60, + "height": 100, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 499, + "fields": { + "data": 28, + "path": "image_2.png", + "frame": 5, + "width": 55, + "height": 72, + "is_placeholder": true, + "real_frame": 24 + } +}, +{ + "model": "engine.image", + "pk": 500, + "fields": { + "data": 28, + "path": "image_15.png", + "frame": 6, + "width": 60, + "height": 59, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 501, + "fields": { + "data": 28, + "path": "image_6.png", + "frame": 7, + "width": 70, + "height": 53, + "is_placeholder": true, + "real_frame": 26 + } +}, +{ + "model": "engine.image", + "pk": 502, + "fields": { + "data": 28, + "path": "image_1.png", + "frame": 8, + "width": 55, + "height": 36, + "is_placeholder": true, + "real_frame": 23 + } +}, +{ + "model": "engine.image", + "pk": 503, + "fields": { + "data": 28, + "path": "image_6.png", + "frame": 9, + "width": 70, + "height": 53, + "is_placeholder": true, + "real_frame": 26 + } +}, +{ + "model": "engine.image", + "pk": 504, + "fields": { + "data": 28, + "path": "image_10.png", + "frame": 10, + "width": 66, + "height": 96, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 505, + "fields": { + "data": 28, + "path": "image_18.png", + "frame": 11, + "width": 54, + "height": 89, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 506, + "fields": { + "data": 28, + "path": "image_0.png", + "frame": 12, + "width": 45, + "height": 92, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 507, + "fields": { + "data": 28, + "path": "image_3.png", + "frame": 13, + "width": 76, + "height": 52, + "is_placeholder": true, + "real_frame": 25 + } +}, +{ + "model": "engine.image", + "pk": 508, + "fields": { + "data": 28, + "path": "image_17.png", + "frame": 14, + "width": 65, + "height": 57, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 509, + "fields": { + "data": 28, + "path": "image_8.png", + "frame": 15, + "width": 31, + "height": 41, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 510, + "fields": { + "data": 28, + "path": "image_2.png", + "frame": 16, + "width": 55, + "height": 72, + "is_placeholder": true, + "real_frame": 24 + } +}, +{ + "model": "engine.image", + "pk": 511, + "fields": { + "data": 28, + "path": "image_16.png", + "frame": 17, + "width": 96, + "height": 58, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 512, + "fields": { + "data": 28, + "path": "image_13.png", + "frame": 18, + "width": 54, + "height": 63, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 513, + "fields": { + "data": 28, + "path": "image_5.png", + "frame": 19, + "width": 91, + "height": 100, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 514, + "fields": { + "data": 28, + "path": "image_12.png", + "frame": 20, + "width": 60, + "height": 32, + "is_placeholder": true, + "real_frame": 28 + } +}, +{ + "model": "engine.image", + "pk": 515, + "fields": { + "data": 28, + "path": "image_19.png", + "frame": 21, + "width": 97, + "height": 88, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 516, + "fields": { + "data": 28, + "path": "image_3.png", + "frame": 22, + "width": 76, + "height": 52, + "is_placeholder": true, + "real_frame": 25 + } +}, +{ + "model": "engine.image", + "pk": 517, + "fields": { + "data": 28, + "path": "image_1.png", + "frame": 23, + "width": 55, + "height": 36, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 518, + "fields": { + "data": 28, + "path": "image_2.png", + "frame": 24, + "width": 55, + "height": 72, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 519, + "fields": { + "data": 28, + "path": "image_3.png", + "frame": 25, + "width": 76, + "height": 52, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 520, + "fields": { + "data": 28, + "path": "image_6.png", + "frame": 26, + "width": 70, + "height": 53, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 521, + "fields": { + "data": 28, + "path": "image_9.png", + "frame": 27, + "width": 74, + "height": 92, + "is_placeholder": false, + "real_frame": 0 + } +}, +{ + "model": "engine.image", + "pk": 522, + "fields": { + "data": 28, + "path": "image_12.png", + "frame": 28, + "width": 60, + "height": 32, + "is_placeholder": false, + "real_frame": 0 } }, { @@ -4096,6 +4807,32 @@ "target_storage": 50 } }, +{ + "model": "engine.task", + "pk": 29, + "fields": { + "created_date": "2024-10-01T12:36:21.364Z", + "updated_date": "2024-10-01T12:38:26.883Z", + "project": null, + "name": "task with honeypots", + "mode": "annotation", + "owner": [ + "admin1" + ], + "assignee": null, + "assignee_updated_date": null, + "bug_tracker": "", + "overlap": 0, + "segment_size": 0, + "status": "annotation", + "data": 28, + "dimension": "2d", + "subset": "", + "organization": 2, + "source_storage": null, + "target_storage": null + } +}, { "model": "engine.clientfile", "pk": 131, @@ -5363,6 +6100,50 @@ "frames": "[]" } }, +{ + "model": "engine.segment", + "pk": 38, + "fields": { + "task": 29, + "start_frame": 0, + "stop_frame": 7, + "type": "range", + "frames": "[]" + } +}, +{ + "model": "engine.segment", + "pk": 39, + "fields": { + "task": 29, + "start_frame": 8, + "stop_frame": 15, + "type": "range", + "frames": "[]" + } +}, +{ + "model": "engine.segment", + "pk": 40, + "fields": { + "task": 29, + "start_frame": 16, + "stop_frame": 22, + "type": "range", + "frames": "[]" + } +}, +{ + "model": "engine.segment", + "pk": 41, + "fields": { + "task": 29, + "start_frame": 23, + "stop_frame": 28, + "type": "range", + "frames": "[]" + } +}, { "model": "engine.job", "pk": 2, @@ -5823,6 +6604,66 @@ "type": "annotation" } }, +{ + "model": "engine.job", + "pk": 38, + "fields": { + "created_date": "2024-10-01T12:36:21.476Z", + "updated_date": "2024-10-01T12:36:21.606Z", + "segment": 38, + "assignee": null, + "assignee_updated_date": null, + "status": "annotation", + "stage": "annotation", + "state": "new", + "type": "annotation" + } +}, +{ + "model": "engine.job", + "pk": 39, + "fields": { + "created_date": "2024-10-01T12:36:21.484Z", + "updated_date": "2024-10-01T12:36:21.628Z", + "segment": 39, + "assignee": null, + "assignee_updated_date": null, + "status": "annotation", + "stage": "annotation", + "state": "new", + "type": "annotation" + } +}, +{ + "model": "engine.job", + "pk": 40, + "fields": { + "created_date": "2024-10-01T12:36:21.491Z", + "updated_date": "2024-10-01T12:36:21.651Z", + "segment": 40, + "assignee": null, + "assignee_updated_date": null, + "status": "annotation", + "stage": "annotation", + "state": "new", + "type": "annotation" + } +}, +{ + "model": "engine.job", + "pk": 41, + "fields": { + "created_date": "2024-10-01T12:36:21.511Z", + "updated_date": "2024-10-01T12:38:26.967Z", + "segment": 41, + "assignee": null, + "assignee_updated_date": null, + "status": "annotation", + "stage": "annotation", + "state": "in progress", + "type": "ground_truth" + } +}, { "model": "engine.label", "pk": 3, @@ -6651,6 +7492,18 @@ "parent": null } }, +{ + "model": "engine.label", + "pk": 77, + "fields": { + "task": 29, + "project": null, + "name": "label", + "color": "#6080c0", + "type": "any", + "parent": null + } +}, { "model": "engine.skeleton", "pk": 1, @@ -6863,6 +7716,18 @@ "values": "1\n2" } }, +{ + "model": "engine.attributespec", + "pk": 15, + "fields": { + "label": 77, + "name": "id", + "mutable": false, + "input_type": "text", + "default_value": "", + "values": "" + } +}, { "model": "engine.labeledimage", "pk": 1, @@ -9291,250 +10156,646 @@ "model": "engine.labeledshape", "pk": 155, "fields": { - "job": 29, - "label": 73, - "frame": 4, + "job": 29, + "label": 73, + "frame": 4, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "19.106867015361786, 71.99510070085489, 200.8463572859764, 196.5581221222874", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 156, + "fields": { + "job": 30, + "label": 73, + "frame": 5, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "55.147965204716456, 27.993821200729144, 354.91261003613545, 129.34078386128022", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 157, + "fields": { + "job": 30, + "label": 73, + "frame": 6, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "55.67655109167208, 27.202181529999507, 314.5855129838001, 333.9054008126259", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 158, + "fields": { + "job": 30, + "label": 73, + "frame": 7, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "30.75535711050179, 51.1681019723419, 245.49246947169377, 374.42159963250197", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 159, + "fields": { + "job": 30, + "label": 73, + "frame": 8, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "78.72917294502258, 28.6186763048172, 456.07723474502563, 214.25403320789337", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 160, + "fields": { + "job": 30, + "label": 73, + "frame": 9, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "40.30229317843805, 20.90870136916601, 277.9420801371325, 141.0943407505747", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 161, + "fields": { + "job": 31, + "label": 73, + "frame": 10, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "37.5426108896736, 44.316280591488976, 109.0776273667816, 115.21824382543673", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 162, + "fields": { + "job": 32, + "label": 73, + "frame": 4, + "group": 0, + "source": "Ground truth", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "24.722413063049316, 37.791320228576296, 270.78543078899384, 610.5770170927044", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 163, + "fields": { + "job": 32, + "label": 73, + "frame": 5, + "group": 0, + "source": "Ground truth", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "87.86122530400826, 22.648517262936366, 277.2987968593843, 128.69934738874508", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 164, + "fields": { + "job": 32, + "label": 73, + "frame": 6, + "group": 0, + "source": "Ground truth", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "8.219268488885064, 76.40050053596497, 46.97136907577624, 76.9621251821518", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 165, + "fields": { + "job": 32, + "label": 73, + "frame": 7, + "group": 0, + "source": "Ground truth", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "40.372303777934576, 55.821463263035184, 232.15283377170636, 366.6659974813465", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 166, + "fields": { + "job": 32, + "label": 73, + "frame": 8, + "group": 0, + "source": "Ground truth", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "114.91701781749725, 49.88939428329468, 426.5192240476608, 201.82309412956238", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 167, + "fields": { + "job": 32, + "label": 73, + "frame": 9, + "group": 0, + "source": "Ground truth", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "69.46096818744991, 42.89721039235519, 143.9282634973515, 85.61091347932779", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 168, + "fields": { + "job": 32, + "label": 73, + "frame": 10, + "group": 0, + "source": "Ground truth", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "35.64345116019285, 190.86810638308634, 41.340930348635084, 213.02496989369502", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 169, + "fields": { + "job": 38, + "label": 77, + "frame": 0, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "19.650000000003274,13.100000000002183,31.850000000004002,18.900000000001455", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 170, + "fields": { + "job": 38, + "label": 77, + "frame": 1, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "18.650000000003274,10.500000000001819,28.650000000003274,15.200000000002547", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 171, + "fields": { + "job": 38, + "label": 77, + "frame": 1, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "18.850000000002183,19.50000000000182,27.05000000000291,24.900000000001455", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 172, + "fields": { + "job": 38, + "label": 77, + "frame": 5, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "26.25000000000182,16.50000000000182,40.95000000000255,23.900000000001455", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 173, + "fields": { + "job": 39, + "label": 77, + "frame": 8, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "14.650000000003274,10.000000000001819,25.750000000003638,17.30000000000109", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 174, + "fields": { + "job": 39, + "label": 77, + "frame": 8, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "30.350000000002183,18.700000000002547,43.05000000000291,26.400000000003274", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 175, + "fields": { + "job": 39, + "label": 77, + "frame": 9, + "group": 0, + "source": "manual", + "type": "rectangle", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "9.200000000002547,34.35000000000218,21.900000000003274,38.55000000000291", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 176, + "fields": { + "job": 39, + "label": 77, + "frame": 9, + "group": 0, + "source": "manual", + "type": "points", + "occluded": false, + "outside": false, + "z_order": 0, + "points": "40.900390625,29.0498046875,48.80000000000291,30.350000000002183,45.10000000000218,39.25000000000182,45.70000000000255,24.450000000002547", + "rotation": 0.0, + "parent": null + } +}, +{ + "model": "engine.labeledshape", + "pk": 177, + "fields": { + "job": 39, + "label": 77, + "frame": 12, "group": 0, "source": "manual", - "type": "rectangle", + "type": "points", "occluded": false, "outside": false, "z_order": 0, - "points": "19.106867015361786, 71.99510070085489, 200.8463572859764, 196.5581221222874", + "points": "16.791015625,32.8505859375,27.858705213058784,37.01258996859542,21.633141273523506,39.77950727505595", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 156, + "pk": 178, "fields": { - "job": 30, - "label": 73, - "frame": 5, + "job": 40, + "label": 77, + "frame": 16, "group": 0, "source": "manual", - "type": "rectangle", + "type": "polygon", "occluded": false, "outside": false, "z_order": 0, - "points": "55.147965204716456, 27.993821200729144, 354.91261003613545, 129.34078386128022", + "points": "29.0498046875,14.2998046875,30.350000000002183,22.00000000000182,20.650000000003274,21.600000000002183,20.650000000003274,11.30000000000291", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 157, + "pk": 179, "fields": { - "job": 30, - "label": 73, - "frame": 6, + "job": 40, + "label": 77, + "frame": 17, "group": 0, "source": "manual", - "type": "rectangle", + "type": "polygon", "occluded": false, "outside": false, "z_order": 0, - "points": "55.67655109167208, 27.202181529999507, 314.5855129838001, 333.9054008126259", + "points": "51.2001953125,10.900390625,56.60000000000218,15.700000000002547,48.400000000003274,20.400000000003274", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 158, + "pk": 180, "fields": { - "job": 30, - "label": 73, - "frame": 7, + "job": 40, + "label": 77, + "frame": 20, "group": 0, "source": "manual", - "type": "rectangle", + "type": "polygon", "occluded": false, "outside": false, "z_order": 0, - "points": "30.75535711050179, 51.1681019723419, 245.49246947169377, 374.42159963250197", + "points": "37.2998046875,7.7001953125,42.400000000003274,11.900000000003274,35.80000000000291,17.200000000002547,28.400000000003274,8.80000000000291,37.400000000003274,12.100000000002183", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 159, + "pk": 181, "fields": { - "job": 30, - "label": 73, - "frame": 8, + "job": 40, + "label": 77, + "frame": 20, "group": 0, "source": "manual", "type": "rectangle", "occluded": false, "outside": false, "z_order": 0, - "points": "78.72917294502258, 28.6186763048172, 456.07723474502563, 214.25403320789337", + "points": "17.600000000002183,14.900000000003274,27.200000000002547,21.600000000004002", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 160, + "pk": 182, "fields": { - "job": 30, - "label": 73, - "frame": 9, + "job": 40, + "label": 77, + "frame": 21, "group": 0, "source": "manual", "type": "rectangle", "occluded": false, "outside": false, "z_order": 0, - "points": "40.30229317843805, 20.90870136916601, 277.9420801371325, 141.0943407505747", + "points": "43.15465253950242,24.59525439814206,55.395253809205315,35.071444674014856", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 161, + "pk": 183, "fields": { - "job": 31, - "label": 73, - "frame": 10, + "job": 40, + "label": 77, + "frame": 22, "group": 0, "source": "manual", "type": "rectangle", "occluded": false, "outside": false, "z_order": 0, - "points": "37.5426108896736, 44.316280591488976, 109.0776273667816, 115.21824382543673", + "points": "38.50000000000182,9.600000000002183,51.80000000000109,17.100000000002183", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 162, + "pk": 184, "fields": { - "job": 32, - "label": 73, - "frame": 4, + "job": 40, + "label": 77, + "frame": 22, "group": 0, - "source": "Ground truth", + "source": "manual", "type": "rectangle", "occluded": false, "outside": false, "z_order": 0, - "points": "24.722413063049316, 37.791320228576296, 270.78543078899384, 610.5770170927044", + "points": "52.10000000000218,17.30000000000291,59.400000000001455,21.500000000003638", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 163, + "pk": 185, "fields": { - "job": 32, - "label": 73, - "frame": 5, + "job": 41, + "label": 77, + "frame": 23, "group": 0, "source": "Ground truth", "type": "rectangle", "occluded": false, "outside": false, "z_order": 0, - "points": "87.86122530400826, 22.648517262936366, 277.2987968593843, 128.69934738874508", + "points": "17.650000000003274,11.30000000000291,30.55000000000291,21.700000000002547", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 164, + "pk": 186, "fields": { - "job": 32, - "label": 73, - "frame": 6, + "job": 41, + "label": 77, + "frame": 24, "group": 0, "source": "Ground truth", "type": "rectangle", "occluded": false, "outside": false, "z_order": 0, - "points": "8.219268488885064, 76.40050053596497, 46.97136907577624, 76.9621251821518", + "points": "18.850000000002183,12.000000000001819,25.850000000002183,19.50000000000182", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 165, + "pk": 187, "fields": { - "job": 32, - "label": 73, - "frame": 7, + "job": 41, + "label": 77, + "frame": 24, "group": 0, "source": "Ground truth", "type": "rectangle", "occluded": false, "outside": false, "z_order": 0, - "points": "40.372303777934576, 55.821463263035184, 232.15283377170636, 366.6659974813465", + "points": "26.150000000003274,25.00000000000182,34.150000000003274,34.50000000000182", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 166, + "pk": 188, "fields": { - "job": 32, - "label": 73, - "frame": 8, + "job": 41, + "label": 77, + "frame": 25, "group": 0, "source": "Ground truth", "type": "rectangle", "occluded": false, "outside": false, "z_order": 0, - "points": "114.91701781749725, 49.88939428329468, 426.5192240476608, 201.82309412956238", + "points": "24.600000000002183,11.500000000001819,37.10000000000218,18.700000000002547", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 167, + "pk": 189, "fields": { - "job": 32, - "label": 73, - "frame": 9, + "job": 41, + "label": 77, + "frame": 27, "group": 0, "source": "Ground truth", "type": "rectangle", "occluded": false, "outside": false, "z_order": 0, - "points": "69.46096818744991, 42.89721039235519, 143.9282634973515, 85.61091347932779", + "points": "17.863216443472993,36.43614886308387,41.266725327279346,42.765472201610464", "rotation": 0.0, "parent": null } }, { "model": "engine.labeledshape", - "pk": 168, + "pk": 190, "fields": { - "job": 32, - "label": 73, - "frame": 10, + "job": 41, + "label": 77, + "frame": 27, "group": 0, "source": "Ground truth", - "type": "rectangle", + "type": "polygon", "occluded": false, "outside": false, "z_order": 0, - "points": "35.64345116019285, 190.86810638308634, 41.340930348635084, 213.02496989369502", + "points": "34.349609375,52.806640625,27.086274131672326,63.1830161588623,40.229131337355284,67.44868033965395,48.87574792004307,59.03264019917333,45.53238950807099,53.3835173651496", "rotation": 0.0, "parent": null } @@ -10682,6 +11943,204 @@ "shape": 130 } }, +{ + "model": "engine.labeledshapeattributeval", + "pk": 128, + "fields": { + "spec": 15, + "value": "j1 frame1 n1", + "shape": 169 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 129, + "fields": { + "spec": 15, + "value": "j1 frame2 n1", + "shape": 170 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 130, + "fields": { + "spec": 15, + "value": "j1 frame2 n2", + "shape": 171 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 131, + "fields": { + "spec": 15, + "value": "j1 frame6 n1", + "shape": 172 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 132, + "fields": { + "spec": 15, + "value": "j2 frame1 n1", + "shape": 173 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 133, + "fields": { + "spec": 15, + "value": "j2 frame1 n2", + "shape": 174 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 134, + "fields": { + "spec": 15, + "value": "j2 frame2 n1", + "shape": 175 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 135, + "fields": { + "spec": 15, + "value": "j2 frame2 n2", + "shape": 176 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 136, + "fields": { + "spec": 15, + "value": "j2 frame5 n1", + "shape": 177 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 137, + "fields": { + "spec": 15, + "value": "j3 frame1 n1", + "shape": 178 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 138, + "fields": { + "spec": 15, + "value": "j3 frame2 n1", + "shape": 179 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 139, + "fields": { + "spec": 15, + "value": "j3 frame5 n1", + "shape": 180 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 140, + "fields": { + "spec": 15, + "value": "j3 frame5 n2", + "shape": 181 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 141, + "fields": { + "spec": 15, + "value": "j3 frame6 n1", + "shape": 182 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 142, + "fields": { + "spec": 15, + "value": "j3 frame7 n1", + "shape": 183 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 143, + "fields": { + "spec": 15, + "value": "j3 frame7 n2", + "shape": 184 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 144, + "fields": { + "spec": 15, + "value": "gt frame1 n1", + "shape": 185 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 145, + "fields": { + "spec": 15, + "value": "gt frame2 n2", + "shape": 186 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 147, + "fields": { + "spec": 15, + "value": "gt frame3 n1", + "shape": 188 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 148, + "fields": { + "spec": 15, + "value": "gt frame5 n1", + "shape": 189 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 149, + "fields": { + "spec": 15, + "value": "gt frame5 n2", + "shape": 190 + } +}, +{ + "model": "engine.labeledshapeattributeval", + "pk": 150, + "fields": { + "spec": 15, + "value": "gt frame2 n1", + "shape": 187 + } +}, { "model": "engine.labeledtrack", "pk": 1, @@ -17153,6 +18612,28 @@ "max_validations_per_job": 0 } }, +{ + "model": "quality_control.qualitysettings", + "pk": 24, + "fields": { + "task": 29, + "iou_threshold": 0.4, + "oks_sigma": 0.09, + "line_thickness": 0.01, + "low_overlap_threshold": 0.8, + "compare_line_orientation": true, + "line_orientation_threshold": 0.1, + "compare_groups": true, + "group_match_threshold": 0.5, + "check_covered_annotations": true, + "object_visibility_threshold": 0.05, + "panoptic_comparison": true, + "compare_attributes": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "max_validations_per_job": 0 + } +}, { "model": "admin.logentry", "pk": 1, diff --git a/tests/python/shared/assets/jobs.json b/tests/python/shared/assets/jobs.json index b489a148905c..2f907fec9186 100644 --- a/tests/python/shared/assets/jobs.json +++ b/tests/python/shared/assets/jobs.json @@ -1,8 +1,144 @@ { - "count": 30, + "count": 34, "next": null, "previous": null, "results": [ + { + "assignee": null, + "assignee_updated_date": null, + "bug_tracker": null, + "created_date": "2024-10-01T12:36:21.511822Z", + "data_chunk_size": 3, + "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", + "dimension": "2d", + "frame_count": 6, + "guide_id": null, + "id": 41, + "issues": { + "count": 0, + "url": "http://localhost:8080/api/issues?job_id=41" + }, + "labels": { + "url": "http://localhost:8080/api/labels?job_id=41" + }, + "mode": "annotation", + "organization": 2, + "project_id": null, + "source_storage": null, + "stage": "annotation", + "start_frame": 23, + "state": "in progress", + "status": "annotation", + "stop_frame": 28, + "target_storage": null, + "task_id": 29, + "type": "ground_truth", + "updated_date": "2024-10-01T12:38:26.967041Z", + "url": "http://localhost:8080/api/jobs/41" + }, + { + "assignee": null, + "assignee_updated_date": null, + "bug_tracker": null, + "created_date": "2024-10-01T12:36:21.491902Z", + "data_chunk_size": 3, + "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", + "dimension": "2d", + "frame_count": 7, + "guide_id": null, + "id": 40, + "issues": { + "count": 0, + "url": "http://localhost:8080/api/issues?job_id=40" + }, + "labels": { + "url": "http://localhost:8080/api/labels?job_id=40" + }, + "mode": "annotation", + "organization": 2, + "project_id": null, + "source_storage": null, + "stage": "annotation", + "start_frame": 16, + "state": "new", + "status": "annotation", + "stop_frame": 22, + "target_storage": null, + "task_id": 29, + "type": "annotation", + "updated_date": "2024-10-01T12:36:21.651801Z", + "url": "http://localhost:8080/api/jobs/40" + }, + { + "assignee": null, + "assignee_updated_date": null, + "bug_tracker": null, + "created_date": "2024-10-01T12:36:21.484944Z", + "data_chunk_size": 3, + "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", + "dimension": "2d", + "frame_count": 8, + "guide_id": null, + "id": 39, + "issues": { + "count": 0, + "url": "http://localhost:8080/api/issues?job_id=39" + }, + "labels": { + "url": "http://localhost:8080/api/labels?job_id=39" + }, + "mode": "annotation", + "organization": 2, + "project_id": null, + "source_storage": null, + "stage": "annotation", + "start_frame": 8, + "state": "new", + "status": "annotation", + "stop_frame": 15, + "target_storage": null, + "task_id": 29, + "type": "annotation", + "updated_date": "2024-10-01T12:36:21.628442Z", + "url": "http://localhost:8080/api/jobs/39" + }, + { + "assignee": null, + "assignee_updated_date": null, + "bug_tracker": null, + "created_date": "2024-10-01T12:36:21.476403Z", + "data_chunk_size": 3, + "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", + "dimension": "2d", + "frame_count": 8, + "guide_id": null, + "id": 38, + "issues": { + "count": 0, + "url": "http://localhost:8080/api/issues?job_id=38" + }, + "labels": { + "url": "http://localhost:8080/api/labels?job_id=38" + }, + "mode": "annotation", + "organization": 2, + "project_id": null, + "source_storage": null, + "stage": "annotation", + "start_frame": 0, + "state": "new", + "status": "annotation", + "stop_frame": 7, + "target_storage": null, + "task_id": 29, + "type": "annotation", + "updated_date": "2024-10-01T12:36:21.606506Z", + "url": "http://localhost:8080/api/jobs/38" + }, { "assignee": null, "assignee_updated_date": null, diff --git a/tests/python/shared/assets/labels.json b/tests/python/shared/assets/labels.json index c1eae29d3396..529b42a80743 100644 --- a/tests/python/shared/assets/labels.json +++ b/tests/python/shared/assets/labels.json @@ -1,5 +1,5 @@ { - "count": 43, + "count": 44, "next": null, "previous": null, "results": [ @@ -850,6 +850,28 @@ "sublabels": [], "task_id": 27, "type": "any" + }, + { + "attributes": [ + { + "default_value": "", + "id": 15, + "input_type": "text", + "mutable": false, + "name": "id", + "values": [ + "" + ] + } + ], + "color": "#6080c0", + "has_parent": false, + "id": 77, + "name": "label", + "parent_id": null, + "sublabels": [], + "task_id": 29, + "type": "any" } ] } \ No newline at end of file diff --git a/tests/python/shared/assets/quality_settings.json b/tests/python/shared/assets/quality_settings.json index 1dd0db102cf3..54e0c18c63a3 100644 --- a/tests/python/shared/assets/quality_settings.json +++ b/tests/python/shared/assets/quality_settings.json @@ -1,5 +1,5 @@ { - "count": 23, + "count": 24, "next": null, "previous": null, "results": [ @@ -439,6 +439,25 @@ "target_metric": "accuracy", "target_metric_threshold": 0.7, "task_id": 28 + }, + { + "check_covered_annotations": true, + "compare_attributes": true, + "compare_groups": true, + "compare_line_orientation": true, + "group_match_threshold": 0.5, + "id": 24, + "iou_threshold": 0.4, + "line_orientation_threshold": 0.1, + "line_thickness": 0.01, + "low_overlap_threshold": 0.8, + "max_validations_per_job": 0, + "object_visibility_threshold": 0.05, + "oks_sigma": 0.09, + "panoptic_comparison": true, + "target_metric": "accuracy", + "target_metric_threshold": 0.7, + "task_id": 29 } ] } \ No newline at end of file diff --git a/tests/python/shared/assets/tasks.json b/tests/python/shared/assets/tasks.json index 4c4acb6bc92b..b4f40bdaef8b 100644 --- a/tests/python/shared/assets/tasks.json +++ b/tests/python/shared/assets/tasks.json @@ -1,8 +1,52 @@ { - "count": 23, + "count": 24, "next": null, "previous": null, "results": [ + { + "assignee": null, + "assignee_updated_date": null, + "bug_tracker": "", + "created_date": "2024-10-01T12:36:21.364662Z", + "data": 28, + "data_chunk_size": 3, + "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", + "dimension": "2d", + "guide_id": null, + "id": 29, + "image_quality": 70, + "jobs": { + "completed": 0, + "count": 4, + "url": "http://localhost:8080/api/jobs?task_id=29", + "validation": 0 + }, + "labels": { + "url": "http://localhost:8080/api/labels?task_id=29" + }, + "mode": "annotation", + "name": "task with honeypots", + "organization": 2, + "overlap": 0, + "owner": { + "first_name": "Admin", + "id": 1, + "last_name": "First", + "url": "http://localhost:8080/api/users/1", + "username": "admin1" + }, + "project_id": null, + "segment_size": 0, + "size": 29, + "source_storage": null, + "status": "annotation", + "subset": "", + "target_storage": null, + "updated_date": "2024-10-01T12:38:26.883235Z", + "url": "http://localhost:8080/api/tasks/29", + "validation_mode": "gt_pool" + }, { "assignee": null, "assignee_updated_date": null, @@ -52,7 +96,8 @@ "location": "local" }, "updated_date": "2024-09-23T21:42:23.082000Z", - "url": "http://localhost:8080/api/tasks/28" + "url": "http://localhost:8080/api/tasks/28", + "validation_mode": null }, { "assignee": { @@ -109,7 +154,8 @@ "location": "local" }, "updated_date": "2024-09-23T10:52:48.778000Z", - "url": "http://localhost:8080/api/tasks/27" + "url": "http://localhost:8080/api/tasks/27", + "validation_mode": null }, { "assignee": { @@ -166,7 +212,8 @@ "location": "local" }, "updated_date": "2024-09-23T10:51:45.533000Z", - "url": "http://localhost:8080/api/tasks/26" + "url": "http://localhost:8080/api/tasks/26", + "validation_mode": null }, { "assignee": null, @@ -217,7 +264,8 @@ "location": "local" }, "updated_date": "2024-07-15T15:34:53.692000Z", - "url": "http://localhost:8080/api/tasks/25" + "url": "http://localhost:8080/api/tasks/25", + "validation_mode": null }, { "assignee": null, @@ -268,7 +316,8 @@ "location": "local" }, "updated_date": "2024-07-15T15:33:10.641000Z", - "url": "http://localhost:8080/api/tasks/24" + "url": "http://localhost:8080/api/tasks/24", + "validation_mode": null }, { "assignee": null, @@ -311,7 +360,8 @@ "subset": "", "target_storage": null, "updated_date": "2024-03-21T20:50:05.947000Z", - "url": "http://localhost:8080/api/tasks/23" + "url": "http://localhost:8080/api/tasks/23", + "validation_mode": "gt" }, { "assignee": null, @@ -354,7 +404,8 @@ "subset": "Train", "target_storage": null, "updated_date": "2023-11-24T15:23:30.045000Z", - "url": "http://localhost:8080/api/tasks/22" + "url": "http://localhost:8080/api/tasks/22", + "validation_mode": "gt" }, { "assignee": null, @@ -405,7 +456,8 @@ "location": "local" }, "updated_date": "2023-03-27T19:08:40.032000Z", - "url": "http://localhost:8080/api/tasks/21" + "url": "http://localhost:8080/api/tasks/21", + "validation_mode": null }, { "assignee": null, @@ -456,7 +508,8 @@ "location": "local" }, "updated_date": "2023-03-10T11:57:48.835000Z", - "url": "http://localhost:8080/api/tasks/20" + "url": "http://localhost:8080/api/tasks/20", + "validation_mode": null }, { "assignee": null, @@ -507,7 +560,8 @@ "location": "local" }, "updated_date": "2023-03-10T11:56:54.904000Z", - "url": "http://localhost:8080/api/tasks/19" + "url": "http://localhost:8080/api/tasks/19", + "validation_mode": null }, { "assignee": null, @@ -558,7 +612,8 @@ "location": "local" }, "updated_date": "2023-03-01T15:36:37.897000Z", - "url": "http://localhost:8080/api/tasks/18" + "url": "http://localhost:8080/api/tasks/18", + "validation_mode": null }, { "assignee": { @@ -607,7 +662,8 @@ "subset": "", "target_storage": null, "updated_date": "2023-02-10T14:08:05.873000Z", - "url": "http://localhost:8080/api/tasks/17" + "url": "http://localhost:8080/api/tasks/17", + "validation_mode": null }, { "assignee": null, @@ -658,7 +714,8 @@ "location": "local" }, "updated_date": "2022-12-01T12:53:35.028000Z", - "url": "http://localhost:8080/api/tasks/15" + "url": "http://localhost:8080/api/tasks/15", + "validation_mode": null }, { "assignee": null, @@ -709,7 +766,8 @@ "location": "local" }, "updated_date": "2022-09-23T11:57:02.300000Z", - "url": "http://localhost:8080/api/tasks/14" + "url": "http://localhost:8080/api/tasks/14", + "validation_mode": null }, { "assignee": { @@ -758,7 +816,8 @@ "subset": "", "target_storage": null, "updated_date": "2023-02-10T11:50:18.414000Z", - "url": "http://localhost:8080/api/tasks/13" + "url": "http://localhost:8080/api/tasks/13", + "validation_mode": null }, { "assignee": null, @@ -795,7 +854,8 @@ "subset": "", "target_storage": null, "updated_date": "2022-03-14T13:24:05.861000Z", - "url": "http://localhost:8080/api/tasks/12" + "url": "http://localhost:8080/api/tasks/12", + "validation_mode": null }, { "assignee": { @@ -852,7 +912,8 @@ "location": "cloud_storage" }, "updated_date": "2022-06-30T08:56:45.594000Z", - "url": "http://localhost:8080/api/tasks/11" + "url": "http://localhost:8080/api/tasks/11", + "validation_mode": null }, { "assignee": { @@ -901,7 +962,8 @@ "subset": "", "target_storage": null, "updated_date": "2022-11-03T13:57:26.007000Z", - "url": "http://localhost:8080/api/tasks/9" + "url": "http://localhost:8080/api/tasks/9", + "validation_mode": null }, { "assignee": { @@ -950,7 +1012,8 @@ "subset": "", "target_storage": null, "updated_date": "2023-05-02T09:28:57.638000Z", - "url": "http://localhost:8080/api/tasks/8" + "url": "http://localhost:8080/api/tasks/8", + "validation_mode": null }, { "assignee": { @@ -999,7 +1062,8 @@ "subset": "", "target_storage": null, "updated_date": "2022-02-21T10:41:38.540000Z", - "url": "http://localhost:8080/api/tasks/7" + "url": "http://localhost:8080/api/tasks/7", + "validation_mode": null }, { "assignee": null, @@ -1042,7 +1106,8 @@ "subset": "", "target_storage": null, "updated_date": "2022-02-16T06:26:54.836000Z", - "url": "http://localhost:8080/api/tasks/6" + "url": "http://localhost:8080/api/tasks/6", + "validation_mode": null }, { "assignee": { @@ -1091,7 +1156,8 @@ "subset": "", "target_storage": null, "updated_date": "2022-02-21T10:40:21.257000Z", - "url": "http://localhost:8080/api/tasks/5" + "url": "http://localhost:8080/api/tasks/5", + "validation_mode": null }, { "assignee": { @@ -1140,7 +1206,8 @@ "subset": "", "target_storage": null, "updated_date": "2021-12-22T07:14:15.234000Z", - "url": "http://localhost:8080/api/tasks/2" + "url": "http://localhost:8080/api/tasks/2", + "validation_mode": null } ] -} +} \ No newline at end of file From 89084d7023b9f0e87af1929020eb637b63569d22 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 1 Oct 2024 16:33:10 +0300 Subject: [PATCH 216/227] Update test assets --- tests/python/shared/assets/jobs.json | 16 ++++++++-------- tests/python/shared/assets/tasks.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/python/shared/assets/jobs.json b/tests/python/shared/assets/jobs.json index 2f907fec9186..d406fcfc637a 100644 --- a/tests/python/shared/assets/jobs.json +++ b/tests/python/shared/assets/jobs.json @@ -7,7 +7,7 @@ "assignee": null, "assignee_updated_date": null, "bug_tracker": null, - "created_date": "2024-10-01T12:36:21.511822Z", + "created_date": "2024-10-01T12:36:21.511000Z", "data_chunk_size": 3, "data_compressed_chunk_type": "imageset", "data_original_chunk_type": "imageset", @@ -34,14 +34,14 @@ "target_storage": null, "task_id": 29, "type": "ground_truth", - "updated_date": "2024-10-01T12:38:26.967041Z", + "updated_date": "2024-10-01T12:38:26.967000Z", "url": "http://localhost:8080/api/jobs/41" }, { "assignee": null, "assignee_updated_date": null, "bug_tracker": null, - "created_date": "2024-10-01T12:36:21.491902Z", + "created_date": "2024-10-01T12:36:21.491000Z", "data_chunk_size": 3, "data_compressed_chunk_type": "imageset", "data_original_chunk_type": "imageset", @@ -68,14 +68,14 @@ "target_storage": null, "task_id": 29, "type": "annotation", - "updated_date": "2024-10-01T12:36:21.651801Z", + "updated_date": "2024-10-01T12:36:21.651000Z", "url": "http://localhost:8080/api/jobs/40" }, { "assignee": null, "assignee_updated_date": null, "bug_tracker": null, - "created_date": "2024-10-01T12:36:21.484944Z", + "created_date": "2024-10-01T12:36:21.484000Z", "data_chunk_size": 3, "data_compressed_chunk_type": "imageset", "data_original_chunk_type": "imageset", @@ -102,14 +102,14 @@ "target_storage": null, "task_id": 29, "type": "annotation", - "updated_date": "2024-10-01T12:36:21.628442Z", + "updated_date": "2024-10-01T12:36:21.628000Z", "url": "http://localhost:8080/api/jobs/39" }, { "assignee": null, "assignee_updated_date": null, "bug_tracker": null, - "created_date": "2024-10-01T12:36:21.476403Z", + "created_date": "2024-10-01T12:36:21.476000Z", "data_chunk_size": 3, "data_compressed_chunk_type": "imageset", "data_original_chunk_type": "imageset", @@ -136,7 +136,7 @@ "target_storage": null, "task_id": 29, "type": "annotation", - "updated_date": "2024-10-01T12:36:21.606506Z", + "updated_date": "2024-10-01T12:36:21.606000Z", "url": "http://localhost:8080/api/jobs/38" }, { diff --git a/tests/python/shared/assets/tasks.json b/tests/python/shared/assets/tasks.json index b4f40bdaef8b..5a28176ef5ec 100644 --- a/tests/python/shared/assets/tasks.json +++ b/tests/python/shared/assets/tasks.json @@ -7,7 +7,7 @@ "assignee": null, "assignee_updated_date": null, "bug_tracker": "", - "created_date": "2024-10-01T12:36:21.364662Z", + "created_date": "2024-10-01T12:36:21.364000Z", "data": 28, "data_chunk_size": 3, "data_compressed_chunk_type": "imageset", @@ -43,7 +43,7 @@ "status": "annotation", "subset": "", "target_storage": null, - "updated_date": "2024-10-01T12:38:26.883235Z", + "updated_date": "2024-10-01T12:38:26.883000Z", "url": "http://localhost:8080/api/tasks/29", "validation_mode": "gt_pool" }, From 575c921a4c630402f1aa07f953da0264bd523665 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 1 Oct 2024 19:12:36 +0300 Subject: [PATCH 217/227] Migrate to m2m relationship for related files --- cvat/apps/engine/cache.py | 2 +- .../migrations/0084_honeypot_support.py | 80 +++++++++++++++++++ cvat/apps/engine/models.py | 4 +- cvat/apps/engine/task.py | 30 +++++-- 4 files changed, 106 insertions(+), 10 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 907e91745747..e3ad9051b45d 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -626,7 +626,7 @@ def _prepare_cloud_preview(self, db_storage: models.CloudStorage) -> DataWithMim def prepare_context_images_chunk(self, db_data: models.Data, frame_number: int) -> DataWithMime: zip_buffer = io.BytesIO() - related_images = db_data.related_files.filter(primary_image__frame=frame_number).all() + related_images = db_data.related_files.filter(images__frame=frame_number).all() if not related_images: return zip_buffer, "" diff --git a/cvat/apps/engine/migrations/0084_honeypot_support.py b/cvat/apps/engine/migrations/0084_honeypot_support.py index c964de0f7ca3..53b2be5e1e58 100644 --- a/cvat/apps/engine/migrations/0084_honeypot_support.py +++ b/cvat/apps/engine/migrations/0084_honeypot_support.py @@ -62,6 +62,62 @@ def init_validation_layout_in_tasks_with_gt_job(apps, schema_editor): ValidationLayout.objects.bulk_create(validation_layouts, batch_size=100) +def init_m2m_for_related_files(apps, schema_editor): + RelatedFile = apps.get_model("engine", "RelatedFile") + + ThroughModel = RelatedFile.images.through + ThroughModel.objects.bulk_create( + ( + ThroughModel(relatedfile_id=related_file_id, image_id=image_id) + for related_file_id, image_id in ( + RelatedFile.objects.filter(primary_image__isnull=False) + .values_list("id", "primary_image_id") + .iterator(chunk_size=1000) + ) + ), + batch_size=1000, + ) + + +def revert_m2m_for_related_files(apps, schema_editor): + RelatedFile = apps.get_model("engine", "RelatedFile") + + if top_related_file_uses := ( + RelatedFile.objects + .annotate(images_count=models.aggregates.Count( + "images", + filter=models.Q(images__is_placeholder=False) + )) + .order_by("-images_count") + .filter(images_count__gt=1) + .values_list("id", "images_count")[:10] + ): + raise Exception( + "Can't run backward migration: " + "there are RelatedFile objects with more than 1 related Image. " + "Top RelatedFile uses: {}".format( + ", ".join(f"\n\tid = {id}: {count}" for id, count in top_related_file_uses) + ) + ) + + ThroughModel = RelatedFile.images.through + + ( + RelatedFile.objects + .annotate(images_count=models.aggregates.Count( + "images", + filter=models.Q(images__is_placeholder=False) + )) + .filter(images_count__gt=0) + .update( + primary_image_id=models.Subquery( + ThroughModel.objects + .filter(relatedfile_id=models.OuterRef("id")) + .values_list("image_id", flat=True)[:1] + ) + ) + ) + class Migration(migrations.Migration): dependencies = [ @@ -168,4 +224,28 @@ class Migration(migrations.Migration): init_validation_layout_in_tasks_with_gt_job, reverse_code=migrations.RunPython.noop, ), + migrations.AddField( + model_name="relatedfile", + name="images", + field=models.ManyToManyField(to="engine.image"), + ), + migrations.RunPython( + init_m2m_for_related_files, + reverse_code=revert_m2m_for_related_files, + ), + migrations.RemoveField( + model_name="relatedfile", + name="primary_image", + field=models.ForeignKey( + null=True, + on_delete=models.deletion.CASCADE, + related_name="related_files", + to="engine.image", + ), + ), + migrations.AlterField( + model_name="relatedfile", + name="images", + field=models.ManyToManyField(to="engine.image", related_name="related_files"), + ), ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 4ea734b19a26..9ea528d95295 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -11,7 +11,7 @@ import uuid from enum import Enum from functools import cached_property -from typing import Any, ClassVar, Collection, Dict, Optional +from typing import Any, ClassVar, Collection, Dict, Optional, Type from django.conf import settings from django.contrib.auth.models import User @@ -660,7 +660,7 @@ class RelatedFile(models.Model): data = models.ForeignKey(Data, on_delete=models.CASCADE, related_name="related_files", default=1, null=True) path = models.FileField(upload_to=upload_path_handler, max_length=1024, storage=MyFileSystemStorage()) - primary_image = models.ForeignKey(Image, on_delete=models.CASCADE, related_name="related_files", null=True) + images = models.ManyToManyField(Image, related_name="related_files") class Meta: default_permissions = () diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 2d3b6ca03ef0..39af76d4a506 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -1226,6 +1226,12 @@ def _update_status(msg: str) -> None: case _: assert False + if len(all_frames) - len(pool_frames) < 1: + raise ValidationError( + "Cannot create task: " + "too few non-honeypot frames left after selecting validation frames" + ) + # Even though the sorting is random overall, # it's convenient to be able to reasonably navigate in the GT job pool_frames = sort( @@ -1332,19 +1338,29 @@ def _update_status(msg: str) -> None: )) if db_task.mode == 'annotation': - models.Image.objects.bulk_create(images) - images = models.Image.objects.filter(data_id=db_data.id) + images = models.Image.objects.bulk_create(images) db_related_files = [ models.RelatedFile( - data=image.data, - primary_image=image, + data=db_data, path=os.path.join(upload_dir, related_file_path), ) - for image in images.all() - for related_file_path in related_images.get(image.path, []) + for related_file_path in set(itertools.chain.from_iterable(related_images.values())) ] - models.RelatedFile.objects.bulk_create(db_related_files) + db_related_files = models.RelatedFile.objects.bulk_create(db_related_files) + db_related_files_by_path = { + os.path.relpath(rf.path.path, upload_dir): rf for rf in db_related_files + } + + ThroughModel = models.RelatedFile.images.through + models.RelatedFile.images.through.objects.bulk_create(( + ThroughModel( + relatedfile_id=db_related_files_by_path[related_file_path].id, + image_id=image.id + ) + for image in images + for related_file_path in related_images.get(image.path, []) + )) else: models.Video.objects.create( data=db_data, From 4c8eb44a893a710f75f1335951f91948f25fe2ab Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 1 Oct 2024 19:39:48 +0300 Subject: [PATCH 218/227] Update test db --- tests/python/shared/assets/cvat_db/data.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/python/shared/assets/cvat_db/data.json b/tests/python/shared/assets/cvat_db/data.json index c54411580c3a..ee3850be4379 100644 --- a/tests/python/shared/assets/cvat_db/data.json +++ b/tests/python/shared/assets/cvat_db/data.json @@ -5767,7 +5767,9 @@ "fields": { "data": 6, "path": "/home/django/data/data/6/raw/test_pointcloud_pcd/related_images/000001_pcd/000001.png", - "primary_image": 360 + "images": [ + 360 + ] } }, { From 565207d88bc5e442689e8feb6d2510184ff11e83 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 1 Oct 2024 19:41:33 +0300 Subject: [PATCH 219/227] Clean up imports --- cvat/apps/engine/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 9ea528d95295..0743e702cd76 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -11,7 +11,7 @@ import uuid from enum import Enum from functools import cached_property -from typing import Any, ClassVar, Collection, Dict, Optional, Type +from typing import Any, ClassVar, Collection, Dict, Optional from django.conf import settings from django.contrib.auth.models import User From 079038a4fa07112c7df1fb1c5684e17ae8ecdcff Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Tue, 1 Oct 2024 23:42:14 +0300 Subject: [PATCH 220/227] Fix failing test --- tests/python/rest_api/test_quality_control.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/python/rest_api/test_quality_control.py b/tests/python/rest_api/test_quality_control.py index 3a9a2928f4b5..4dd8439eee0f 100644 --- a/tests/python/rest_api/test_quality_control.py +++ b/tests/python/rest_api/test_quality_control.py @@ -1362,10 +1362,10 @@ def test_can_compute_quality_if_non_skeleton_label_follows_skeleton_label( (_, response) = api_client.quality_api.retrieve_report_data(report["id"]) assert response.status == HTTPStatus.OK - @pytest.mark.parametrize("task_id", [26]) def test_excluded_gt_job_frames_are_not_included_in_honeypot_task_quality_report( - self, admin_user, task_id: int, jobs + self, admin_user, tasks, jobs ): + task_id = next(t["id"] for t in tasks if t["validation_mode"] == "gt_pool") gt_job = next(j for j in jobs if j["task_id"] == task_id if j["type"] == "ground_truth") gt_job_frames = range(gt_job["start_frame"], gt_job["stop_frame"] + 1) From 2a674af010681760c3f233cbe37e5e52348db652 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Wed, 2 Oct 2024 11:14:38 +0300 Subject: [PATCH 221/227] Updated client part --- cvat-ui/src/actions/tasks-actions.ts | 8 +- .../create-task-page/create-task-content.tsx | 63 +++++----- .../quality-configuration-form.tsx | 116 +++++++++--------- .../task-quality/allocation-table.tsx | 3 + 4 files changed, 94 insertions(+), 96 deletions(-) diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index d277c6733b3c..60e4da022ef2 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -262,12 +262,8 @@ ThunkAction { validation_params: { mode: data.quality.validationMode, frame_selection_method: data.quality.frameSelectionMethod, - frame_share: data.quality.validationFramesPercent ? ( - data.quality.validationFramesPercent / 100 - ) : data.quality.validationFramesPercent, - frames_per_job_share: data.quality.validationFramesPerJobPercent ? ( - data.quality.validationFramesPerJobPercent / 100 - ) : data.quality.validationFramesPerJobPercent, + frame_share: data.quality.validationFramesPercent, + frames_per_job_share: data.quality.validationFramesPerJobPercent, }, }; } diff --git a/cvat-ui/src/components/create-task-page/create-task-content.tsx b/cvat-ui/src/components/create-task-page/create-task-content.tsx index e4161e96701c..b1e4dc69008f 100644 --- a/cvat-ui/src/components/create-task-page/create-task-content.tsx +++ b/cvat-ui/src/components/create-task-page/create-task-content.tsx @@ -22,7 +22,6 @@ import { RemoteFile } from 'components/file-manager/remote-browser'; import { getFileContentType, getContentTypeRemoteFile, getFileNameFromPath } from 'utils/files'; import { FrameSelectionMethod } from 'components/create-job-page/job-form'; -import { ArgumentError } from 'cvat-core/src/exceptions'; import BasicConfigurationForm, { BaseConfiguration } from './basic-configuration-form'; import ProjectSearchField from './project-search-field'; import ProjectSubsetField from './project-subset-field'; @@ -441,7 +440,7 @@ class CreateTaskContent extends React.PureComponent => new Promise((resolve, reject) => { - const { projectId, quality } = this.state; + const { projectId } = this.state; if (!this.validateLabelsOrProject()) { notification.error({ message: 'Could not create a task', @@ -452,21 +451,6 @@ class CreateTaskContent extends React.PureComponent { - const promises = []; + const promises: Promise[] = []; if (this.advancedConfigurationComponent.current) { promises.push(this.advancedConfigurationComponent.current.submit()); @@ -495,7 +479,33 @@ class CreateTaskContent extends React.PureComponent((_resolve, _reject) => { + Promise.all(promises).then(() => { + const { quality, advanced } = this.state; + if ( + quality.validationMode === ValidationMode.HONEYPOTS && + advanced.sortingMethod !== SortingMethod.RANDOM + ) { + this.setState({ + advanced: { + ...advanced, + sortingMethod: SortingMethod.RANDOM, + }, + }, () => { + _resolve(); + notification.info({ + message: 'Task parameters were automatically updated', + description: 'Sorting method has been updated as Honeypots' + + ' quality method only supports RANDOM sorting', + }); + }); + } else { + _resolve(); + } + }).catch(_reject); + }); + + return formIsValid; }).then(() => { if (projectId) { return core.projects.get({ id: projectId }).then((response) => { @@ -779,21 +789,6 @@ class CreateTaskContent extends React.PureComponent; initialValues: QualityConfiguration; frameSelectionMethod: FrameSelectionMethod; - onChangeFrameSelectionMethod: (method: FrameSelectionMethod) => void; validationMode: ValidationMode; + onSubmit(values: QualityConfiguration): Promise; + onChangeFrameSelectionMethod: (method: FrameSelectionMethod) => void; onChangeValidationMode: (method: ValidationMode) => void; } @@ -44,9 +44,16 @@ export default class QualityConfigurationForm extends React.PureComponent public submit(): Promise { const { onSubmit } = this.props; if (this.formRef.current) { - return this.formRef.current.validateFields().then((values: QualityConfiguration) => { - onSubmit(values); - }); + return this.formRef.current.validateFields().then((values: QualityConfiguration) => onSubmit({ + ...values, + ...(typeof values.validationFramesPercent === 'number' ? { + validationFramesPercent: values.validationFramesPercent / 100, + } : {}), + ...(typeof values.validationFramesPerJobPercent === 'number' ? { + validationFramesPerJobPercent: values.validationFramesPerJobPercent / 100, + } : {}), + }), + ); } return Promise.reject(new Error('Quality form ref is empty')); @@ -78,57 +85,54 @@ export default class QualityConfigurationForm extends React.PureComponent { - (frameSelectionMethod === FrameSelectionMethod.RANDOM) ? - ( - - +value} - rules={[ - { required: true, message: 'The field is required' }, - { - type: 'number', min: 0, max: 100, message: 'Value is not valid', - }, - ]} - > - } - /> - - - ) : ('') + frameSelectionMethod === FrameSelectionMethod.RANDOM && ( + + +value} + rules={[ + { required: true, message: 'The field is required' }, + { + type: 'number', min: 0, max: 100, message: 'Value is not valid', + }, + ]} + > + } + /> + + + ) } - { - (frameSelectionMethod === FrameSelectionMethod.RANDOM_PER_JOB) ? - ( - - +value} - rules={[ - { required: true, message: 'The field is required' }, - { - type: 'number', min: 0, max: 100, message: 'Value is not valid', - }, - ]} - > - } - /> - - - ) : ('') + frameSelectionMethod === FrameSelectionMethod.RANDOM_PER_JOB && ( + + +value} + rules={[ + { required: true, message: 'The field is required' }, + { + type: 'number', min: 0, max: 100, message: 'Value is not valid', + }, + ]} + > + } + /> + + + ) } ); 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 0c53b282b2e7..673a8f3aa04e 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 @@ -37,6 +37,9 @@ 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 From 9d9b00798b9d59bf4dbaa2c1f0edf0bf1cfa3657 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 2 Oct 2024 12:17:08 +0300 Subject: [PATCH 222/227] Add related field declaration in Image and Data models --- cvat/apps/engine/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 0743e702cd76..7813108b767d 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -291,6 +291,8 @@ class Data(models.Model): sorting_method = models.CharField(max_length=15, choices=SortingMethod.choices(), default=SortingMethod.LEXICOGRAPHICAL) deleted_frames = IntArrayField(store_sorted=True, unique_values=True) + related_files: models.manager.RelatedManager[RelatedFile] + validation_params: ValidationParams """ Represents user-requested validation params before task is created. @@ -399,6 +401,7 @@ class Image(models.Model): height = models.PositiveIntegerField() is_placeholder = models.BooleanField(default=False) real_frame = models.PositiveIntegerField(default=0) + related_files: models.manager.RelatedManager[RelatedFile] class Meta: default_permissions = () From d71b5df9399d885e421ec713eb4d89e7ce1f8673 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Wed, 2 Oct 2024 12:19:18 +0300 Subject: [PATCH 223/227] Fixed warning --- .../quality-configuration-form.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/cvat-ui/src/components/create-task-page/quality-configuration-form.tsx b/cvat-ui/src/components/create-task-page/quality-configuration-form.tsx index be149418706c..f7cbb9c3ff27 100644 --- a/cvat-ui/src/components/create-task-page/quality-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/quality-configuration-form.tsx @@ -7,10 +7,11 @@ import Input from 'antd/lib/input'; import Form, { FormInstance } from 'antd/lib/form'; import { PercentageOutlined } from '@ant-design/icons'; import Radio from 'antd/lib/radio'; -import { FrameSelectionMethod } from 'components/create-job-page/job-form'; import { Col, Row } from 'antd/lib/grid'; import Select from 'antd/lib/select'; +import { FrameSelectionMethod } from 'components/create-job-page/job-form'; + export interface QualityConfiguration { validationMode: ValidationMode; validationFramesPercent?: number; @@ -46,6 +47,8 @@ export default class QualityConfigurationForm extends React.PureComponent if (this.formRef.current) { return this.formRef.current.validateFields().then((values: QualityConfiguration) => onSubmit({ ...values, + frameSelectionMethod: values.validationMode === ValidationMode.HONEYPOTS ? + FrameSelectionMethod.RANDOM : values.frameSelectionMethod, ...(typeof values.validationFramesPercent === 'number' ? { validationFramesPercent: values.validationFramesPercent / 100, } : {}), @@ -141,15 +144,6 @@ export default class QualityConfigurationForm extends React.PureComponent private honeypotsParamsBlock(): JSX.Element { return ( - -