From 6171464ef1c917a627ac5253cb0d06bc67feaa7f Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 31 May 2024 17:14:27 +0530 Subject: [PATCH 001/301] model changes to include consensus_job_per_segment under Task and source_job_id under Job --- ...e_job_id_task_consensus_job_per_segment.py | 23 +++++++++++++++++++ .../0080_alter_job_source_job_id.py | 18 +++++++++++++++ .../0081_alter_job_source_job_id.py | 18 +++++++++++++++ ...82_alter_task_consensus_job_per_segment.py | 18 +++++++++++++++ ...83_alter_task_consensus_job_per_segment.py | 18 +++++++++++++++ ...task_consensus_job_per_segment_and_more.py | 22 ++++++++++++++++++ ...85_alter_data_consensus_job_per_segment.py | 18 +++++++++++++++ ...86_alter_data_consensus_job_per_segment.py | 18 +++++++++++++++ cvat/apps/engine/models.py | 2 ++ 9 files changed, 155 insertions(+) create mode 100644 cvat/apps/engine/migrations/0079_job_source_job_id_task_consensus_job_per_segment.py create mode 100644 cvat/apps/engine/migrations/0080_alter_job_source_job_id.py create mode 100644 cvat/apps/engine/migrations/0081_alter_job_source_job_id.py create mode 100644 cvat/apps/engine/migrations/0082_alter_task_consensus_job_per_segment.py create mode 100644 cvat/apps/engine/migrations/0083_alter_task_consensus_job_per_segment.py create mode 100644 cvat/apps/engine/migrations/0084_remove_task_consensus_job_per_segment_and_more.py create mode 100644 cvat/apps/engine/migrations/0085_alter_data_consensus_job_per_segment.py create mode 100644 cvat/apps/engine/migrations/0086_alter_data_consensus_job_per_segment.py diff --git a/cvat/apps/engine/migrations/0079_job_source_job_id_task_consensus_job_per_segment.py b/cvat/apps/engine/migrations/0079_job_source_job_id_task_consensus_job_per_segment.py new file mode 100644 index 00000000000..2ef469179ac --- /dev/null +++ b/cvat/apps/engine/migrations/0079_job_source_job_id_task_consensus_job_per_segment.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.11 on 2024-05-30 12:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0078_alter_cloudstorage_credentials"), + ] + + operations = [ + migrations.AddField( + model_name="job", + name="source_job_id", + field=models.PositiveIntegerField(null=True), + ), + migrations.AddField( + model_name="task", + name="consensus_job_per_segment", + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/cvat/apps/engine/migrations/0080_alter_job_source_job_id.py b/cvat/apps/engine/migrations/0080_alter_job_source_job_id.py new file mode 100644 index 00000000000..42bb6f219b7 --- /dev/null +++ b/cvat/apps/engine/migrations/0080_alter_job_source_job_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-05-30 13:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0079_job_source_job_id_task_consensus_job_per_segment"), + ] + + operations = [ + migrations.AlterField( + model_name="job", + name="source_job_id", + field=models.PositiveIntegerField(default=None, null=True), + ), + ] diff --git a/cvat/apps/engine/migrations/0081_alter_job_source_job_id.py b/cvat/apps/engine/migrations/0081_alter_job_source_job_id.py new file mode 100644 index 00000000000..44947500a68 --- /dev/null +++ b/cvat/apps/engine/migrations/0081_alter_job_source_job_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-05-30 13:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0080_alter_job_source_job_id"), + ] + + operations = [ + migrations.AlterField( + model_name="job", + name="source_job_id", + field=models.PositiveIntegerField(blank=True, default=None, null=True), + ), + ] diff --git a/cvat/apps/engine/migrations/0082_alter_task_consensus_job_per_segment.py b/cvat/apps/engine/migrations/0082_alter_task_consensus_job_per_segment.py new file mode 100644 index 00000000000..cd515280e18 --- /dev/null +++ b/cvat/apps/engine/migrations/0082_alter_task_consensus_job_per_segment.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-05-30 13:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0081_alter_job_source_job_id"), + ] + + operations = [ + migrations.AlterField( + model_name="task", + name="consensus_job_per_segment", + field=models.PositiveIntegerField(default=1), + ), + ] diff --git a/cvat/apps/engine/migrations/0083_alter_task_consensus_job_per_segment.py b/cvat/apps/engine/migrations/0083_alter_task_consensus_job_per_segment.py new file mode 100644 index 00000000000..91a3440f7e3 --- /dev/null +++ b/cvat/apps/engine/migrations/0083_alter_task_consensus_job_per_segment.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-05-31 01:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0082_alter_task_consensus_job_per_segment"), + ] + + operations = [ + migrations.AlterField( + model_name="task", + name="consensus_job_per_segment", + field=models.PositiveIntegerField(blank=True, default=1), + ), + ] diff --git a/cvat/apps/engine/migrations/0084_remove_task_consensus_job_per_segment_and_more.py b/cvat/apps/engine/migrations/0084_remove_task_consensus_job_per_segment_and_more.py new file mode 100644 index 00000000000..1a5248eb655 --- /dev/null +++ b/cvat/apps/engine/migrations/0084_remove_task_consensus_job_per_segment_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.11 on 2024-05-31 02:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0083_alter_task_consensus_job_per_segment"), + ] + + operations = [ + migrations.RemoveField( + model_name="task", + name="consensus_job_per_segment", + ), + migrations.AddField( + model_name="data", + name="consensus_job_per_segment", + field=models.PositiveIntegerField(blank=True, default=1), + ), + ] diff --git a/cvat/apps/engine/migrations/0085_alter_data_consensus_job_per_segment.py b/cvat/apps/engine/migrations/0085_alter_data_consensus_job_per_segment.py new file mode 100644 index 00000000000..9da6d7f1fe3 --- /dev/null +++ b/cvat/apps/engine/migrations/0085_alter_data_consensus_job_per_segment.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-05-31 02:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0084_remove_task_consensus_job_per_segment_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="data", + name="consensus_job_per_segment", + field=models.IntegerField(blank=True, default=1), + ), + ] diff --git a/cvat/apps/engine/migrations/0086_alter_data_consensus_job_per_segment.py b/cvat/apps/engine/migrations/0086_alter_data_consensus_job_per_segment.py new file mode 100644 index 00000000000..b771af615f5 --- /dev/null +++ b/cvat/apps/engine/migrations/0086_alter_data_consensus_job_per_segment.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-05-31 10:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0085_alter_data_consensus_job_per_segment"), + ] + + operations = [ + migrations.AlterField( + model_name="data", + name="consensus_job_per_segment", + field=models.IntegerField(blank=True, default=0), + ), + ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 907937382a1..ae17b166857 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -232,6 +232,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) + consensus_job_per_segment = models.IntegerField(default=0, blank=True) class Meta: default_permissions = () @@ -677,6 +678,7 @@ class Job(TimestampedModel): type = models.CharField(max_length=32, choices=JobType.choices(), default=JobType.ANNOTATION) + source_job_id = models.PositiveIntegerField(null=True, blank=True, default=None) def get_target_storage(self) -> Optional[Storage]: return self.segment.task.target_storage From 539419778ae95ead29c5c9647cce1f9f45dce268 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 31 May 2024 17:15:49 +0530 Subject: [PATCH 002/301] basic backend changes to include the 2 newly added paramter across the backend --- cvat/apps/dataset_manager/bindings.py | 2 ++ cvat/apps/dataset_manager/project.py | 1 + cvat/apps/engine/admin.py | 2 +- cvat/apps/engine/backup.py | 1 + cvat/apps/engine/serializers.py | 17 ++++++++++++++--- cvat/apps/engine/task.py | 5 +++++ 6 files changed, 24 insertions(+), 4 deletions(-) diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 21735b16082..f09b9976bc4 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -675,6 +675,7 @@ def _init_meta(self): ("start_frame", str(self._db_data.start_frame + db_segment.start_frame * self._frame_step)), ("stop_frame", str(self._db_data.start_frame + db_segment.stop_frame * self._frame_step)), ("frame_filter", self._db_data.frame_filter), + ("source_job_id", str(self._db_job.source_job_id)), ("segments", [ ("segment", OrderedDict([ ("id", str(db_segment.id)), @@ -779,6 +780,7 @@ def meta_for_task(db_task, host, label_mapping=None): ("start_frame", str(db_task.data.start_frame)), ("stop_frame", str(db_task.data.stop_frame)), ("frame_filter", db_task.data.frame_filter), + ("consensus_job_per_segment", str(db_task.data.consensus_job_per_segment)), ("segments", [ ("segment", OrderedDict([ diff --git a/cvat/apps/dataset_manager/project.py b/cvat/apps/dataset_manager/project.py index 35a283f53d5..34ba2b9b00d 100644 --- a/cvat/apps/dataset_manager/project.py +++ b/cvat/apps/dataset_manager/project.py @@ -85,6 +85,7 @@ def split_name(file): "use_cache": False, "use_zip_chunks": True, "image_quality": 70, + "consensus_job_per_segment": 0, }) data_serializer.is_valid(raise_exception=True) db_data = data_serializer.save() diff --git a/cvat/apps/engine/admin.py b/cvat/apps/engine/admin.py index 05e4b40a0f9..e500c84dd8b 100644 --- a/cvat/apps/engine/admin.py +++ b/cvat/apps/engine/admin.py @@ -59,7 +59,7 @@ def has_add_permission(self, _request, obj): class DataAdmin(admin.ModelAdmin): model = Data - fields = ('chunk_size', 'size', 'image_quality', 'start_frame', 'stop_frame', 'frame_filter', 'compressed_chunk_type', 'original_chunk_type') + fields = ('chunk_size', 'size', 'image_quality', 'start_frame', 'stop_frame', 'frame_filter', 'compressed_chunk_type', 'original_chunk_type', 'consensus_job_per_segment') readonly_fields = fields autocomplete_fields = ('cloud_storage', ) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index a3a63c082b7..b48326a1697 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -201,6 +201,7 @@ def _prepare_data_meta(self, data): 'deleted_frames', 'custom_segments', 'job_file_mapping', + 'consensus_job_per_segment' } self._prepare_meta(allowed_fields, data) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 37c06694dc5..a9d62d44238 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -593,6 +593,7 @@ class JobReadSerializer(serializers.ModelSerializer): issues = IssuesSummarySerializer(source='*') target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) + source_job_id = serializers.ReadOnlyField(allow_null=True) class Meta: model = models.Job @@ -600,7 +601,7 @@ class Meta: 'dimension', 'bug_tracker', 'status', 'stage', 'state', 'mode', 'frame_count', 'start_frame', 'stop_frame', 'data_chunk_size', 'data_compressed_chunk_type', 'created_date', 'updated_date', 'issues', 'labels', 'type', 'organization', - 'target_storage', 'source_storage') + 'target_storage', 'source_storage', 'source_job_id') read_only_fields = fields def to_representation(self, instance): @@ -964,6 +965,10 @@ class DataSerializer(serializers.ModelSerializer): pass the list of file names in the required order. """.format(models.SortingMethod.PREDEFINED)) ) + consensus_job_per_segment = serializers.IntegerField(default=1, + help_text=textwrap.dedent("""\ + Number of Consensus Jobs for each Normal Job. + """)) class Meta: model = models.Data @@ -973,7 +978,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', 'consensus_job_per_segment', ) extra_kwargs = { 'chunk_size': { 'help_text': "Maximum number of frames per chunk" }, @@ -1096,6 +1101,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='*') + consensus_job_per_segment = serializers.IntegerField(source='data.consensus_job_per_segment', required=False) class Meta: model = models.Task @@ -1104,6 +1110,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', + 'consensus_job_per_segment' ) read_only_fields = fields extra_kwargs = { @@ -1119,12 +1126,13 @@ class TaskWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): project_id = serializers.IntegerField(required=False, allow_null=True) target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) + consensus_job_per_segment = serializers.IntegerField(required=False) 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', 'consensus_job_per_segment' ) write_once_fields = ('overlap', 'segment_size') @@ -1184,6 +1192,7 @@ def update(self, instance, validated_data): instance.bug_tracker) instance.subset = validated_data.get('subset', instance.subset) labels = validated_data.get('label_set', []) + instance.consensus_job_per_segment = validated_data.get('consensus_job_per_segment', instance.consensus_job_per_segment) if instance.project_id is None: LabelSerializer.update_labels(labels, parent_instance=instance) @@ -1444,6 +1453,7 @@ class DataMetaReadSerializer(serializers.ModelSerializer): help_text=textwrap.dedent("""\ A list of valid frame ids. The None value means all frames are included. """)) + consensus_job_per_segment = serializers.IntegerField(default=1) class Meta: model = models.Data @@ -1457,6 +1467,7 @@ class Meta: 'frames', 'deleted_frames', 'included_frames', + 'consensus_job_per_segment', ) read_only_fields = fields extra_kwargs = { diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 192f2b68bb4..a0b7f1cad1e 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -172,6 +172,11 @@ def _save_task_to_db(db_task: models.Task, *, job_file_mapping: Optional[JobFile db_job.save() db_job.make_dirs() + for _ in range(db_task.data.consensus_job_per_segment): + consensus_db_job = models.Job(segment=db_segment, source_job_id=db_job.id) + consensus_db_job.save() + consensus_db_job.make_dirs() + db_task.data.save() db_task.save() From 15bc9b863621d315fc537e3a4eb7404f723b4743 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 31 May 2024 17:16:34 +0530 Subject: [PATCH 003/301] TaskAnnotations should consist of consensus jobs --- 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 f87aaf02fb1..6a879e5845a 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -730,7 +730,7 @@ def __init__(self, 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, + segment__task_id=pk, type=models.JobType.ANNOTATION.value, source_job_id=None ).order_by('id') self.ir_data = AnnotationIR(self.db_task.dimension) From cb1856b228d517dbe46ad02122d3574e5c6195db Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 31 May 2024 17:17:33 +0530 Subject: [PATCH 004/301] consensus_job_per_segment configurable during task creation --- .../advanced-configuration-form.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 f39afbe367f..8e5ae69c2ad 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 @@ -49,6 +49,7 @@ export interface AdvancedConfiguration { useProjectTargetStorage: boolean; sourceStorage: StorageData; targetStorage: StorageData; + consensusJobPerSegment?: number; } const initialValues: AdvancedConfiguration = { @@ -68,6 +69,7 @@ const initialValues: AdvancedConfiguration = { location: StorageLocation.LOCAL, cloudStorageId: undefined, }, + consensusJobPerSegment: 0, }; interface Props { @@ -325,6 +327,14 @@ class AdvancedConfigurationForm extends React.PureComponent { ); } + private renderConsensusJobPerSegment(): JSX.Element { + return ( + + + + ); + } + private renderBugTracker(): JSX.Element { return ( { {this.renderChunkSize()} + + {this.renderConsensusJobPerSegment()} + From b0783e0bcae9179187a964c6594c8125003fd288 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 31 May 2024 17:18:17 +0530 Subject: [PATCH 005/301] passing consensus_job_per_segment from frontend to backend --- cvat-core/src/server-response-types.ts | 2 ++ cvat-core/src/session-implementation.ts | 1 + cvat-core/src/session.ts | 10 ++++++++++ cvat-ui/src/actions/tasks-actions.ts | 4 ++++ 4 files changed, 17 insertions(+) diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index 0bcce7cfb67..933895c5aab 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -120,6 +120,7 @@ export interface SerializedTask { subset: string; updated_date: string; url: string; + consensus_job_per_segment: number; } export interface SerializedJob { @@ -146,6 +147,7 @@ export interface SerializedJob { url: string; source_storage: SerializedStorage | null; target_storage: SerializedStorage | null; + source_job_id: number | null; } export type AttrInputType = 'select' | 'radio' | 'checkbox' | 'number' | 'text'; diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 124424f78fb..54c30bf07c5 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -483,6 +483,7 @@ export function implementTask(Task) { use_zip_chunks: this.useZipChunks, use_cache: this.useCache, sorting_method: this.sortingMethod, + consensus_job_per_segment: this.consensusJobPerSegment, ...(typeof this.startFrame !== 'undefined' ? { start_frame: this.startFrame } : {}), ...(typeof this.stopFrame !== 'undefined' ? { stop_frame: this.stopFrame } : {}), ...(typeof this.frameFilter !== 'undefined' ? { frame_filter: this.frameFilter } : {}), diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 5565417ff9e..832a3e3084f 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -454,6 +454,7 @@ export class Job extends Session { updated_date: undefined, source_storage: undefined, target_storage: undefined, + source_job_id: null, }; const updateTrigger = new FieldUpdateTrigger(); @@ -588,6 +589,9 @@ export class Job extends Session { bugTracker: { get: () => data.bug_tracker, }, + sourceID: { + get: () => data.source_job_id, + }, createdDate: { get: () => data.created_date, }, @@ -667,6 +671,7 @@ export class Task extends Session { public readonly organization: number | null; public readonly progress: { count: number; completed: number }; public readonly jobs: Job[]; + public readonly consensusJobPerSegment: number; public readonly startFrame: number; public readonly stopFrame: number; @@ -721,6 +726,7 @@ export class Task extends Session { cloud_storage_id: undefined, sorting_method: undefined, files: undefined, + consensus_job_per_segment: undefined, quality_settings: undefined, }; @@ -798,6 +804,7 @@ export class Task extends Session { data_chunk_size: data.data_chunk_size, target_storage: initialData.target_storage, source_storage: initialData.source_storage, + source_job_id: job.source_job_id, }); data.jobs.push(jobInstance); } @@ -905,6 +912,9 @@ export class Task extends Session { copyData: { get: () => data.copy_data, }, + consensusJobPerSegment: { + get: () => data.consensus_job_per_segment, + }, labels: { get: () => [...data.labels], set: (labels: Label[]) => { diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index 57b948dc47c..596f9018787 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -213,6 +213,7 @@ ThunkAction, {}, {}, AnyAction> { sorting_method: data.advanced.sortingMethod, source_storage: new Storage(data.advanced.sourceStorage || { location: StorageLocation.LOCAL }).toJSON(), target_storage: new Storage(data.advanced.targetStorage || { location: StorageLocation.LOCAL }).toJSON(), + consensus_job_per_segment: 0, }; if (data.projectId) { @@ -251,6 +252,9 @@ ThunkAction, {}, {}, AnyAction> { if (data.cloudStorageId) { description.cloud_storage_id = data.cloudStorageId; } + if (data.advanced.consensusJobPerSegment) { + description.consensus_job_per_segment = data.advanced.consensusJobPerSegment; + } const taskInstance = new cvat.classes.Task(description); taskInstance.clientFiles = data.files.local; From 0b5acbde9d764d0da208794bbe25bf13e136dada Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 31 May 2024 17:19:09 +0530 Subject: [PATCH 006/301] including consensus_job_per_segment in cvat-sdk Task --- cvat-sdk/cvat_sdk/core/proxies/tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cvat-sdk/cvat_sdk/core/proxies/tasks.py b/cvat-sdk/cvat_sdk/core/proxies/tasks.py index 509928f4dd5..7ddebdd8e9e 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/tasks.py +++ b/cvat-sdk/cvat_sdk/core/proxies/tasks.py @@ -78,7 +78,7 @@ def upload_data( """ params = params or {} - data = {"image_quality": 70} + data = {"image_quality": 70, 'consensus_job_per_segment': 0} data.update( filter_dict( @@ -96,6 +96,7 @@ def upload_data( "filename_pattern", "cloud_storage_id", "server_files_exclude", + "consensus_job_per_segment", ], ) ) From 38800c4abcac820ab74f4d619aea5e04d500db65 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 2 Jun 2024 08:32:47 +0530 Subject: [PATCH 007/301] removed consensus_job_per_segment from TaskWriteSerializer as this shouldn't be updated once Task is created --- cvat/apps/engine/serializers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index a9d62d44238..877309a9860 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1126,13 +1126,12 @@ class TaskWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): project_id = serializers.IntegerField(required=False, allow_null=True) target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) - consensus_job_per_segment = serializers.IntegerField(required=False) 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', 'consensus_job_per_segment' + 'target_storage', 'source_storage', ) write_once_fields = ('overlap', 'segment_size') @@ -1192,7 +1191,6 @@ def update(self, instance, validated_data): instance.bug_tracker) instance.subset = validated_data.get('subset', instance.subset) labels = validated_data.get('label_set', []) - instance.consensus_job_per_segment = validated_data.get('consensus_job_per_segment', instance.consensus_job_per_segment) if instance.project_id is None: LabelSerializer.update_labels(labels, parent_instance=instance) From a076e9b23697ae85f73d4442efe817d7b0c321ed Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 2 Jun 2024 08:53:36 +0530 Subject: [PATCH 008/301] validation in UI form so consensusJobPerSegment is between [0,10]-{1} --- .../create-task-page/advanced-configuration-form.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 8e5ae69c2ad..3049362b0a9 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 @@ -94,7 +94,7 @@ function validateURL(_: RuleObject, value: string): Promise { return Promise.resolve(); } -const isInteger = ({ min, max }: { min?: number; max?: number }) => ( +const isInteger = ({ min, max, toBeSkipped }: { min?: number; max?: number; toBeSkipped?: number }) => ( _: RuleObject, value?: number | string, ): Promise => { @@ -115,6 +115,10 @@ const isInteger = ({ min, max }: { min?: number; max?: number }) => ( return Promise.reject(new Error(`Value must be less than ${max}`)); } + if (typeof toBeSkipped !== 'undefined' && intValue === toBeSkipped) { + return Promise.reject(new Error(`Value shouldn't be equal to ${toBeSkipped}`)); + } + return Promise.resolve(); }; @@ -329,7 +333,11 @@ class AdvancedConfigurationForm extends React.PureComponent { private renderConsensusJobPerSegment(): JSX.Element { return ( - + ); From 760bec56f6a115a098a79073f97aa74d66ad6038 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 4 Jun 2024 05:06:26 +0530 Subject: [PATCH 009/301] added some comments --- cvat/apps/engine/models.py | 3 +++ cvat/apps/engine/task.py | 1 + 2 files changed, 4 insertions(+) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index ae17b166857..db4e4598d41 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -217,6 +217,9 @@ class IntArrayField(AbstractArrayField): converter = int class Data(models.Model): + """ + Information received about a task, through API request. + """ chunk_size = models.PositiveIntegerField(null=True) size = models.PositiveIntegerField(default=0) image_quality = models.PositiveSmallIntegerField(default=50) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index a0b7f1cad1e..391833e755d 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -172,6 +172,7 @@ def _save_task_to_db(db_task: models.Task, *, job_file_mapping: Optional[JobFile db_job.save() db_job.make_dirs() + # consensus jobs use the same `db_segment` as the normal job, thus data not duplicated in backups, exports for _ in range(db_task.data.consensus_job_per_segment): consensus_db_job = models.Job(segment=db_segment, source_job_id=db_job.id) consensus_db_job.save() From fc65894178bf9b8f1b6c905897a3a4b36da1dc35 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 4 Jun 2024 05:08:06 +0530 Subject: [PATCH 010/301] consensus_job_per_segment default value as 0 --- 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 877309a9860..bf5844eca7b 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1451,7 +1451,7 @@ class DataMetaReadSerializer(serializers.ModelSerializer): help_text=textwrap.dedent("""\ A list of valid frame ids. The None value means all frames are included. """)) - consensus_job_per_segment = serializers.IntegerField(default=1) + consensus_job_per_segment = serializers.IntegerField(default=0) class Meta: model = models.Data From 8dc940d27f41dfab809bef23c08caba04f427257 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 4 Jun 2024 05:16:23 +0530 Subject: [PATCH 011/301] created a separate tab for configuring value of consensus_job_per_segment --- cvat-ui/src/actions/tasks-actions.ts | 4 +- .../advanced-configuration-form.tsx | 2 - .../consensus-configuration-form.tsx | 122 ++++++++++++++++++ .../create-task-page/create-task-content.tsx | 41 ++++++ 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index 596f9018787..d87345574d1 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -252,8 +252,8 @@ ThunkAction, {}, {}, AnyAction> { if (data.cloudStorageId) { description.cloud_storage_id = data.cloudStorageId; } - if (data.advanced.consensusJobPerSegment) { - description.consensus_job_per_segment = data.advanced.consensusJobPerSegment; + if (data.consensus.consensusJobPerSegment) { + description.consensus_job_per_segment = +data.consensus.consensusJobPerSegment; } const taskInstance = new cvat.classes.Task(description); 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 3049362b0a9..d308a9ccf0d 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 @@ -49,7 +49,6 @@ export interface AdvancedConfiguration { useProjectTargetStorage: boolean; sourceStorage: StorageData; targetStorage: StorageData; - consensusJobPerSegment?: number; } const initialValues: AdvancedConfiguration = { @@ -69,7 +68,6 @@ const initialValues: AdvancedConfiguration = { location: StorageLocation.LOCAL, cloudStorageId: undefined, }, - consensusJobPerSegment: 0, }; interface Props { diff --git a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx new file mode 100644 index 00000000000..db7895b533e --- /dev/null +++ b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx @@ -0,0 +1,122 @@ +// Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022-2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import React, { RefObject } from 'react'; +import { Row, Col } from 'antd/lib/grid'; +import Input from 'antd/lib/input'; +import Form, { FormInstance, RuleObject } from 'antd/lib/form'; +import { Store } from 'antd/lib/form/interface'; + +export interface ConsensusConfiguration { + consensusJobPerSegment?: number; +} + +const initialValues: ConsensusConfiguration = { + consensusJobPerSegment: 0, +}; + +interface Props { + onSubmit(values: ConsensusConfiguration): Promise; +} + +const isInteger = ({ + min, + max, + toBeSkipped, +}: { min?: number; max?: number; toBeSkipped?: number }) => ( + _: RuleObject, + value?: number | string, +): Promise => { + if (typeof value === 'undefined' || value === '') { + return Promise.resolve(); + } + + const intValue = +value; + if (Number.isNaN(intValue) || !Number.isInteger(intValue)) { + return Promise.reject(new Error('Value must be a positive integer')); + } + + if (typeof min !== 'undefined' && intValue < min) { + return Promise.reject(new Error(`Value must be more than ${min}`)); + } + + if (typeof max !== 'undefined' && intValue > max) { + return Promise.reject(new Error(`Value must be less than ${max}`)); + } + + if (typeof toBeSkipped !== 'undefined' && intValue === toBeSkipped) { + return Promise.reject(new Error(`Value shouldn't be equal to ${toBeSkipped}`)); + } + + return Promise.resolve(); +}; + +class ConsensusConfigurationForm extends React.PureComponent { + private formRef: RefObject; + + public constructor(props: Props) { + super(props); + this.formRef = React.createRef(); + } + + public submit(): Promise { + const { + onSubmit, + } = this.props; + + if (this.formRef.current) { + return this.formRef.current.validateFields() + .then( + (values: Store): Promise => { + const entries = Object.entries(values); + return onSubmit({ + ...((Object.fromEntries(entries) as any) as ConsensusConfiguration), + }); + }, + ); + } + + return Promise.reject(new Error('Form ref is empty')); + } + + public resetFields(): void { + if (this.formRef.current) { + this.formRef.current.resetFields(); + } + } + + /* eslint-disable class-methods-use-this */ + private renderConsensusJobPerSegment(): JSX.Element { + return ( + + + + ); + } + + public render(): JSX.Element { + return ( +
+ + + {this.renderConsensusJobPerSegment()} + + +
+ ); + } +} + +export default ConsensusConfigurationForm; 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 13941d56533..71a7968db51 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 @@ -26,6 +26,7 @@ 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 ConsensusConfigurationForm, { ConsensusConfiguration } from './consensus-configuration-form'; type TabName = 'local' | 'share' | 'remote' | 'cloudStorage'; const core = getCore(); @@ -35,6 +36,7 @@ export interface CreateTaskData { basic: BaseConfiguration; subset: string; advanced: AdvancedConfiguration; + consensus: ConsensusConfiguration; labels: any[]; files: Files; activeFileManagerTab: TabName; @@ -83,6 +85,7 @@ const defaultState: State = { useProjectSourceStorage: true, useProjectTargetStorage: true, }, + consensus: {}, labels: [], files: { local: [], @@ -152,6 +155,7 @@ function filterFiles(remoteFiles: RemoteFile[], many: boolean): RemoteFile[] { class CreateTaskContent extends React.PureComponent { private basicConfigurationComponent: RefObject; private advancedConfigurationComponent: RefObject; + private consensusConfigurationComponent: RefObject; private fileManagerComponent: any; public constructor(props: Props & RouteComponentProps) { @@ -159,6 +163,7 @@ class CreateTaskContent extends React.PureComponent(); this.advancedConfigurationComponent = React.createRef(); + this.consensusConfigurationComponent = React.createRef(); } public componentDidMount(): void { @@ -185,6 +190,7 @@ class CreateTaskContent extends React.PureComponent { this.basicConfigurationComponent.current?.resetFields(); this.advancedConfigurationComponent.current?.resetFields(); + this.consensusConfigurationComponent.current?.resetFields(); this.fileManagerComponent.reset(); @@ -254,6 +260,15 @@ class CreateTaskContent extends React.PureComponent => ( + new Promise((resolve) => { + console.log(values); + this.setState({ + consensus: { ...values }, + }, resolve); + }) + ); + private handleTaskSubsetChange = (value: string): void => { this.setState({ subset: value, @@ -434,6 +449,9 @@ class CreateTaskContent extends React.PureComponent { @@ -564,6 +582,7 @@ class CreateTaskContent extends React.PureComponent + Consensus configuration, + children: ( + + ), + }]} + /> + + ); + } + private renderFooterSingleTask(): JSX.Element { const { uploadFileErrorMessage, loading, statusInProgressTask: status } = this.state; @@ -966,6 +1006,7 @@ class CreateTaskContent extends React.PureComponent {many ? this.renderFooterMultiTasks() : this.renderFooterSingleTask() } From 837db2fcca9da71f5d1ba17152f3d2046b8eed2c Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 5 Jun 2024 04:43:24 +0530 Subject: [PATCH 012/301] renamed source_job_id to parent_job_id --- cvat-core/src/server-response-types.ts | 2 +- cvat-core/src/session.ts | 9 +++++---- cvat/apps/dataset_manager/bindings.py | 2 +- cvat/apps/dataset_manager/task.py | 6 ++++-- cvat/apps/engine/models.py | 2 +- cvat/apps/engine/serializers.py | 4 ++-- cvat/apps/engine/task.py | 2 +- 7 files changed, 15 insertions(+), 12 deletions(-) diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index 933895c5aab..86919138df2 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -147,7 +147,7 @@ export interface SerializedJob { url: string; source_storage: SerializedStorage | null; target_storage: SerializedStorage | null; - source_job_id: number | null; + parent_job_id: number | null; } export type AttrInputType = 'select' | 'radio' | 'checkbox' | 'number' | 'text'; diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 832a3e3084f..e0ae9cd5069 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -429,6 +429,7 @@ export class Job extends Session { public readonly updatedDate: string; public readonly sourceStorage: Storage; public readonly targetStorage: Storage; + public readonly parentJobId: number; constructor(initialData: Readonly & { labels?: SerializedLabel[] }>) { super(); @@ -454,7 +455,7 @@ export class Job extends Session { updated_date: undefined, source_storage: undefined, target_storage: undefined, - source_job_id: null, + parent_job_id: null, }; const updateTrigger = new FieldUpdateTrigger(); @@ -589,9 +590,6 @@ export class Job extends Session { bugTracker: { get: () => data.bug_tracker, }, - sourceID: { - get: () => data.source_job_id, - }, createdDate: { get: () => data.created_date, }, @@ -610,6 +608,9 @@ export class Job extends Session { _initialData: { get: () => initialData, }, + parentJobId: { + get: () => data.parent_job_id, + }, }), ); } diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index f09b9976bc4..009c6d4ffaa 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -675,7 +675,7 @@ def _init_meta(self): ("start_frame", str(self._db_data.start_frame + db_segment.start_frame * self._frame_step)), ("stop_frame", str(self._db_data.start_frame + db_segment.stop_frame * self._frame_step)), ("frame_filter", self._db_data.frame_filter), - ("source_job_id", str(self._db_job.source_job_id)), + ("parent_job_id", str(self._db_job.parent_job_id)), ("segments", [ ("segment", OrderedDict([ ("id", str(db_segment.id)), diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 6a879e5845a..01fb41a66d7 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -730,7 +730,7 @@ def __init__(self, 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, source_job_id=None + segment__task_id=pk, type=models.JobType.ANNOTATION.value, ).order_by('id') self.ir_data = AnnotationIR(self.db_task.dimension) @@ -742,6 +742,8 @@ def _patch_data(self, data, action): splitted_data = {} jobs = {} for db_job in self.db_jobs: + if db_job.parent_job_id is not None: + continue jid = db_job.id start = db_job.segment.start_frame stop = db_job.segment.stop_frame @@ -782,7 +784,7 @@ def init_from_db(self): self.reset() for db_job in self.db_jobs: - if db_job.type != models.JobType.ANNOTATION: + if db_job.type != models.JobType.ANNOTATION and db_job.parent_job_id is not None: continue annotation = JobAnnotation(db_job.id, is_prefetched=True) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index db4e4598d41..79c5411360b 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -681,7 +681,7 @@ class Job(TimestampedModel): type = models.CharField(max_length=32, choices=JobType.choices(), default=JobType.ANNOTATION) - source_job_id = models.PositiveIntegerField(null=True, blank=True, default=None) + parent_job_id = models.PositiveIntegerField(null=True, blank=True, default=None) def get_target_storage(self) -> Optional[Storage]: return self.segment.task.target_storage diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index bf5844eca7b..8de88fab060 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -593,7 +593,7 @@ class JobReadSerializer(serializers.ModelSerializer): issues = IssuesSummarySerializer(source='*') target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) - source_job_id = serializers.ReadOnlyField(allow_null=True) + parent_job_id = serializers.ReadOnlyField(allow_null=True) class Meta: model = models.Job @@ -601,7 +601,7 @@ class Meta: 'dimension', 'bug_tracker', 'status', 'stage', 'state', 'mode', 'frame_count', 'start_frame', 'stop_frame', 'data_chunk_size', 'data_compressed_chunk_type', 'created_date', 'updated_date', 'issues', 'labels', 'type', 'organization', - 'target_storage', 'source_storage', 'source_job_id') + 'target_storage', 'source_storage', 'parent_job_id') read_only_fields = fields def to_representation(self, instance): diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 567fa2dd20a..5031d8ac818 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -174,7 +174,7 @@ def _save_task_to_db(db_task: models.Task, *, job_file_mapping: Optional[JobFile # consensus jobs use the same `db_segment` as the normal job, thus data not duplicated in backups, exports for _ in range(db_task.data.consensus_job_per_segment): - consensus_db_job = models.Job(segment=db_segment, source_job_id=db_job.id) + consensus_db_job = models.Job(segment=db_segment, parent_job_id=db_job.id) consensus_db_job.save() consensus_db_job.make_dirs() From 366b8f030d65b1ec5cc58d5ef270da44e23ce054 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 5 Jun 2024 04:47:52 +0530 Subject: [PATCH 013/301] added agrement_score_threhold parameter --- cvat-core/src/server-response-types.ts | 1 + cvat-core/src/session-implementation.ts | 1 + cvat-core/src/session.ts | 7 +++- cvat-sdk/cvat_sdk/core/proxies/tasks.py | 3 +- cvat-ui/src/actions/tasks-actions.ts | 5 ++- .../advanced-configuration-form.tsx | 15 ------- .../consensus-configuration-form.tsx | 40 ++++++++++++++++--- .../create-task-page/create-task-content.tsx | 1 - cvat/apps/dataset_manager/bindings.py | 1 + cvat/apps/dataset_manager/project.py | 1 + cvat/apps/engine/admin.py | 2 +- cvat/apps/engine/backup.py | 3 +- ...data_agreement_score_threshold_and_more.py | 23 +++++++++++ ...ource_job_id_job_parent_job_id_and_more.py | 23 +++++++++++ cvat/apps/engine/models.py | 1 + cvat/apps/engine/serializers.py | 17 ++++++-- 16 files changed, 113 insertions(+), 31 deletions(-) create mode 100644 cvat/apps/engine/migrations/0087_data_agreement_score_threshold_and_more.py create mode 100644 cvat/apps/engine/migrations/0088_rename_source_job_id_job_parent_job_id_and_more.py diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index 86919138df2..2501dc7f865 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -121,6 +121,7 @@ export interface SerializedTask { updated_date: string; url: string; consensus_job_per_segment: number; + agreement_score_threshold: number; } export interface SerializedJob { diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 54c30bf07c5..40222eb94c3 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -484,6 +484,7 @@ export function implementTask(Task) { use_cache: this.useCache, sorting_method: this.sortingMethod, consensus_job_per_segment: this.consensusJobPerSegment, + agreement_score_threshold: this.agreementScoreThreshold, ...(typeof this.startFrame !== 'undefined' ? { start_frame: this.startFrame } : {}), ...(typeof this.stopFrame !== 'undefined' ? { stop_frame: this.stopFrame } : {}), ...(typeof this.frameFilter !== 'undefined' ? { frame_filter: this.frameFilter } : {}), diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index e0ae9cd5069..d9dcc2dddfe 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -673,6 +673,7 @@ export class Task extends Session { public readonly progress: { count: number; completed: number }; public readonly jobs: Job[]; public readonly consensusJobPerSegment: number; + public readonly agreementScoreThreshold: number; public readonly startFrame: number; public readonly stopFrame: number; @@ -728,6 +729,7 @@ export class Task extends Session { sorting_method: undefined, files: undefined, consensus_job_per_segment: undefined, + agreement_score_threshold: undefined, quality_settings: undefined, }; @@ -805,7 +807,7 @@ export class Task extends Session { data_chunk_size: data.data_chunk_size, target_storage: initialData.target_storage, source_storage: initialData.source_storage, - source_job_id: job.source_job_id, + parent_job_id: job.parent_job_id, }); data.jobs.push(jobInstance); } @@ -916,6 +918,9 @@ export class Task extends Session { consensusJobPerSegment: { get: () => data.consensus_job_per_segment, }, + agreementScoreThreshold: { + get: () => data.agreement_score_threshold, + }, labels: { get: () => [...data.labels], set: (labels: Label[]) => { diff --git a/cvat-sdk/cvat_sdk/core/proxies/tasks.py b/cvat-sdk/cvat_sdk/core/proxies/tasks.py index 7ddebdd8e9e..5d61749969a 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/tasks.py +++ b/cvat-sdk/cvat_sdk/core/proxies/tasks.py @@ -78,7 +78,7 @@ def upload_data( """ params = params or {} - data = {"image_quality": 70, 'consensus_job_per_segment': 0} + data = {"image_quality": 70, "consensus_job_per_segment": 0, "agreement_score_threshold": 0} data.update( filter_dict( @@ -97,6 +97,7 @@ def upload_data( "cloud_storage_id", "server_files_exclude", "consensus_job_per_segment", + "agreement_score_threshold", ], ) ) diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index d87345574d1..fcce3d5de64 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -214,6 +214,7 @@ ThunkAction, {}, {}, AnyAction> { source_storage: new Storage(data.advanced.sourceStorage || { location: StorageLocation.LOCAL }).toJSON(), target_storage: new Storage(data.advanced.targetStorage || { location: StorageLocation.LOCAL }).toJSON(), consensus_job_per_segment: 0, + agreement_score_threshold: 0, }; if (data.projectId) { @@ -255,7 +256,9 @@ ThunkAction, {}, {}, AnyAction> { if (data.consensus.consensusJobPerSegment) { description.consensus_job_per_segment = +data.consensus.consensusJobPerSegment; } - + if (data.consensus.agreementScoreThreshold) { + description.agreement_score_threshold = data.consensus.agreementScoreThreshold; + } const taskInstance = new cvat.classes.Task(description); taskInstance.clientFiles = data.files.local; taskInstance.serverFiles = data.files.share.concat(data.files.cloudStorage); 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 d308a9ccf0d..e538beef6df 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 @@ -329,18 +329,6 @@ class AdvancedConfigurationForm extends React.PureComponent { ); } - private renderConsensusJobPerSegment(): JSX.Element { - return ( - - - - ); - } - private renderBugTracker(): JSX.Element { return ( { {this.renderChunkSize()} - - {this.renderConsensusJobPerSegment()} - diff --git a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx index db7895b533e..18fe16945a3 100644 --- a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx @@ -11,21 +11,24 @@ import { Store } from 'antd/lib/form/interface'; export interface ConsensusConfiguration { consensusJobPerSegment?: number; + agreementScoreThreshold?: number; } const initialValues: ConsensusConfiguration = { consensusJobPerSegment: 0, + agreementScoreThreshold: 0, }; interface Props { onSubmit(values: ConsensusConfiguration): Promise; } -const isInteger = ({ +const isNumber = ({ min, max, toBeSkipped, -}: { min?: number; max?: number; toBeSkipped?: number }) => ( + strictInt, +}: { min?: number; max?: number; toBeSkipped?: number, strictInt?: boolean }) => ( _: RuleObject, value?: number | string, ): Promise => { @@ -34,8 +37,12 @@ const isInteger = ({ } const intValue = +value; - if (Number.isNaN(intValue) || !Number.isInteger(intValue)) { - return Promise.reject(new Error('Value must be a positive integer')); + if (Number.isFinite(intValue)) { + if (strictInt && !Number.isInteger(intValue)) { + return Promise.reject(new Error('Value must be a positive integer')); + } + } else { + return Promise.reject(new Error('Value must be a finite number')); } if (typeof min !== 'undefined' && intValue < min) { @@ -94,10 +101,11 @@ class ConsensusConfigurationForm extends React.PureComponent { label='Consensus Job Per Segment' name='consensusJobPerSegment' rules={[{ - validator: isInteger({ + validator: isNumber({ min: 0, max: 10, toBeSkipped: 1, + strictInt: true, }), }]} > @@ -106,13 +114,33 @@ class ConsensusConfigurationForm extends React.PureComponent { ); } + private renderAgreementScoreThreshold(): JSX.Element { + return ( + + + + ); + } + public render(): JSX.Element { return (
- + {this.renderConsensusJobPerSegment()} + + {this.renderAgreementScoreThreshold()} +
); 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 71a7968db51..ad1a5373a1f 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 @@ -262,7 +262,6 @@ class CreateTaskContent extends React.PureComponent => ( new Promise((resolve) => { - console.log(values); this.setState({ consensus: { ...values }, }, resolve); diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 009c6d4ffaa..81e460fe6f5 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -781,6 +781,7 @@ def meta_for_task(db_task, host, label_mapping=None): ("stop_frame", str(db_task.data.stop_frame)), ("frame_filter", db_task.data.frame_filter), ("consensus_job_per_segment", str(db_task.data.consensus_job_per_segment)), + ("agreement_score_threshold", str(db_task.data.agreement_score_threshold)), ("segments", [ ("segment", OrderedDict([ diff --git a/cvat/apps/dataset_manager/project.py b/cvat/apps/dataset_manager/project.py index 34ba2b9b00d..f4ec62d8ea2 100644 --- a/cvat/apps/dataset_manager/project.py +++ b/cvat/apps/dataset_manager/project.py @@ -86,6 +86,7 @@ def split_name(file): "use_zip_chunks": True, "image_quality": 70, "consensus_job_per_segment": 0, + "agreement_score_threshold": 0, }) data_serializer.is_valid(raise_exception=True) db_data = data_serializer.save() diff --git a/cvat/apps/engine/admin.py b/cvat/apps/engine/admin.py index e500c84dd8b..1b96cfa27d0 100644 --- a/cvat/apps/engine/admin.py +++ b/cvat/apps/engine/admin.py @@ -59,7 +59,7 @@ def has_add_permission(self, _request, obj): class DataAdmin(admin.ModelAdmin): model = Data - fields = ('chunk_size', 'size', 'image_quality', 'start_frame', 'stop_frame', 'frame_filter', 'compressed_chunk_type', 'original_chunk_type', 'consensus_job_per_segment') + fields = ('chunk_size', 'size', 'image_quality', 'start_frame', 'stop_frame', 'frame_filter', 'compressed_chunk_type', 'original_chunk_type', 'consensus_job_per_segment', 'agreement_score_threshold') readonly_fields = fields autocomplete_fields = ('cloud_storage', ) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index b48326a1697..b7808bcf5b7 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -201,7 +201,8 @@ def _prepare_data_meta(self, data): 'deleted_frames', 'custom_segments', 'job_file_mapping', - 'consensus_job_per_segment' + 'consensus_job_per_segment', + 'agreement_score_threshold', } self._prepare_meta(allowed_fields, data) diff --git a/cvat/apps/engine/migrations/0087_data_agreement_score_threshold_and_more.py b/cvat/apps/engine/migrations/0087_data_agreement_score_threshold_and_more.py new file mode 100644 index 00000000000..e2a5f9057f7 --- /dev/null +++ b/cvat/apps/engine/migrations/0087_data_agreement_score_threshold_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.11 on 2024-06-04 12:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0086_alter_data_consensus_job_per_segment"), + ] + + operations = [ + migrations.AddField( + model_name="data", + name="agreement_score_threshold", + field=models.FloatField(blank=True, default=0), + ), + migrations.AlterField( + model_name="data", + name="consensus_job_per_segment", + field=models.PositiveSmallIntegerField(blank=True, default=0), + ), + ] diff --git a/cvat/apps/engine/migrations/0088_rename_source_job_id_job_parent_job_id_and_more.py b/cvat/apps/engine/migrations/0088_rename_source_job_id_job_parent_job_id_and_more.py new file mode 100644 index 00000000000..2d5ecc4d3d9 --- /dev/null +++ b/cvat/apps/engine/migrations/0088_rename_source_job_id_job_parent_job_id_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.11 on 2024-06-04 22:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0087_data_agreement_score_threshold_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="job", + old_name="source_job_id", + new_name="parent_job_id", + ), + migrations.AlterField( + model_name="data", + name="consensus_job_per_segment", + field=models.IntegerField(blank=True, default=0), + ), + ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 79c5411360b..127cc9ed605 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -236,6 +236,7 @@ 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) consensus_job_per_segment = models.IntegerField(default=0, blank=True) + agreement_score_threshold = models.FloatField(default=0, blank=True) class Meta: default_permissions = () diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 8de88fab060..b1d800c0122 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -965,10 +965,14 @@ class DataSerializer(serializers.ModelSerializer): pass the list of file names in the required order. """.format(models.SortingMethod.PREDEFINED)) ) - consensus_job_per_segment = serializers.IntegerField(default=1, + consensus_job_per_segment = serializers.IntegerField(default=0, help_text=textwrap.dedent("""\ Number of Consensus Jobs for each Normal Job. """)) + agreement_score_threshold = serializers.FloatField(default=0, + help_text=textwrap.dedent("""\ + Agreement Score Threshold, for merging consensus jobs. + """)) class Meta: model = models.Data @@ -979,6 +983,7 @@ class Meta: 'cloud_storage_id', 'use_cache', 'copy_data', 'storage_method', 'storage', 'sorting_method', 'filename_pattern', 'job_file_mapping', 'upload_file_order', 'consensus_job_per_segment', + 'agreement_score_threshold', ) extra_kwargs = { 'chunk_size': { 'help_text': "Maximum number of frames per chunk" }, @@ -1101,7 +1106,8 @@ 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='*') - consensus_job_per_segment = serializers.IntegerField(source='data.consensus_job_per_segment', required=False) + consensus_job_per_segment = serializers.ReadOnlyField(source='data.consensus_job_per_segment', required=False) + agreement_score_threshold = serializers.FloatField(source='data.agreement_score_threshold', required=False) class Meta: model = models.Task @@ -1110,7 +1116,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', - 'consensus_job_per_segment' + 'consensus_job_per_segment', 'agreement_score_threshold' ) read_only_fields = fields extra_kwargs = { @@ -1126,12 +1132,13 @@ class TaskWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): project_id = serializers.IntegerField(required=False, allow_null=True) target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) + agreement_score_threshold = serializers.FloatField(required=False) 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', 'agreement_score_threshold' ) write_once_fields = ('overlap', 'segment_size') @@ -1452,6 +1459,7 @@ class DataMetaReadSerializer(serializers.ModelSerializer): A list of valid frame ids. The None value means all frames are included. """)) consensus_job_per_segment = serializers.IntegerField(default=0) + agreement_score_threshold = serializers.FloatField(default=0) class Meta: model = models.Data @@ -1466,6 +1474,7 @@ class Meta: 'deleted_frames', 'included_frames', 'consensus_job_per_segment', + 'agreement_score_threshold', ) read_only_fields = fields extra_kwargs = { From 3f2592c0b29a0b9bce16eb22b906281478bcfd8a Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 6 Jun 2024 18:28:06 +0530 Subject: [PATCH 014/301] added feature to change agreement_score_threshold after task creation --- cvat-core/src/session-implementation.ts | 1 + cvat-core/src/session.ts | 13 +++++- .../consensus-configuration-editor.tsx | 41 +++++++++++++++++++ cvat-ui/src/components/task-page/details.tsx | 10 +++++ cvat/apps/engine/models.py | 3 -- cvat/apps/engine/serializers.py | 3 +- 6 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 cvat-ui/src/components/task-page/consensus-configuration-editor.tsx diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 40222eb94c3..b1fdd9a4f66 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -406,6 +406,7 @@ export function implementTask(Task) { bugTracker: 'bug_tracker', projectId: 'project_id', assignee: 'assignee_id', + agreementScoreThreshold: 'agreement_score_threshold', }); if (taskData.assignee_id) { diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index d9dcc2dddfe..0ffc9da3466 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -673,7 +673,7 @@ export class Task extends Session { public readonly progress: { count: number; completed: number }; public readonly jobs: Job[]; public readonly consensusJobPerSegment: number; - public readonly agreementScoreThreshold: number; + public agreementScoreThreshold: number; public readonly startFrame: number; public readonly stopFrame: number; @@ -920,6 +920,17 @@ export class Task extends Session { }, agreementScoreThreshold: { get: () => data.agreement_score_threshold, + set: (value: number) => { + if (typeof value !== 'number') { + throw new ArgumentError( + `Agreement Score Threshold value must be a Number. But ${typeof value} has been got.`, + ); + } + + updateTrigger.update('agreementScoreThreshold'); + data.agreement_score_threshold = value; + console.log(data); + }, }, labels: { get: () => [...data.labels], diff --git a/cvat-ui/src/components/task-page/consensus-configuration-editor.tsx b/cvat-ui/src/components/task-page/consensus-configuration-editor.tsx new file mode 100644 index 00000000000..924923f9965 --- /dev/null +++ b/cvat-ui/src/components/task-page/consensus-configuration-editor.tsx @@ -0,0 +1,41 @@ +// Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022-2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import React, { useState } from 'react'; + +import { Col, Row } from 'antd/lib/grid'; +import Text from 'antd/lib/typography/Text'; + +import { Task } from 'cvat-core-wrapper'; + +interface Props { + instance: Task; + onChange: (agreementScoreThreshold: number) => void; +} + +export default function ConsensusConfigurationEditorComponent(props: Props): JSX.Element { + const { instance, onChange } = props; + + const [agreementScoreThreshold, setAgreementScoreThreshold] = useState(instance.agreementScoreThreshold); + + const onChangeValue = (value: string): void => { + const val = parseFloat(value); + setAgreementScoreThreshold(val); + onChange(val); + }; + + return ( + + + + Agreement Score Threshold + + + {agreementScoreThreshold} + + + + ); +} diff --git a/cvat-ui/src/components/task-page/details.tsx b/cvat-ui/src/components/task-page/details.tsx index d721c2bdb43..5734f076651 100644 --- a/cvat-ui/src/components/task-page/details.tsx +++ b/cvat-ui/src/components/task-page/details.tsx @@ -19,6 +19,7 @@ import { cancelInferenceAsync } from 'actions/models-actions'; import { CombinedState, ActiveInference } from 'reducers'; import UserSelector from './user-selector'; import BugTrackerEditor from './bug-tracker-editor'; +import ConsensusConfigurationEditor from './consensus-configuration-editor'; import LabelsEditorComponent from '../labels-editor/labels-editor'; import ProjectSubsetField from '../create-task-page/project-subset-field'; @@ -237,6 +238,15 @@ class DetailsComponent extends React.PureComponent { }} /> + + { + taskInstance.agreementScoreThreshold = value; + onUpdateTask(taskInstance); + }} + /> + Date: Thu, 6 Jun 2024 20:43:06 +0530 Subject: [PATCH 015/301] moved consensus_job_per_segment and agreement_score_threshold from Data model to Task model --- cvat-core/src/session.ts | 2 +- cvat/apps/dataset_manager/bindings.py | 4 +-- cvat/apps/dataset_manager/project.py | 2 -- cvat/apps/engine/admin.py | 2 +- ...data_agreement_score_threshold_and_more.py | 31 +++++++++++++++++++ cvat/apps/engine/models.py | 4 +-- cvat/apps/engine/serializers.py | 21 +++---------- cvat/apps/engine/task.py | 2 +- 8 files changed, 42 insertions(+), 26 deletions(-) create mode 100644 cvat/apps/engine/migrations/0089_remove_data_agreement_score_threshold_and_more.py diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 0ffc9da3466..6f2cce5bf81 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -429,7 +429,7 @@ export class Job extends Session { public readonly updatedDate: string; public readonly sourceStorage: Storage; public readonly targetStorage: Storage; - public readonly parentJobId: number; + public readonly parentJobId: number | null; constructor(initialData: Readonly & { labels?: SerializedLabel[] }>) { super(); diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 81e460fe6f5..9eb7fd0f7db 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -780,8 +780,8 @@ def meta_for_task(db_task, host, label_mapping=None): ("start_frame", str(db_task.data.start_frame)), ("stop_frame", str(db_task.data.stop_frame)), ("frame_filter", db_task.data.frame_filter), - ("consensus_job_per_segment", str(db_task.data.consensus_job_per_segment)), - ("agreement_score_threshold", str(db_task.data.agreement_score_threshold)), + ("consensus_job_per_segment", str(db_task.consensus_job_per_segment)), + ("agreement_score_threshold", str(db_task.agreement_score_threshold)), ("segments", [ ("segment", OrderedDict([ diff --git a/cvat/apps/dataset_manager/project.py b/cvat/apps/dataset_manager/project.py index f4ec62d8ea2..35a283f53d5 100644 --- a/cvat/apps/dataset_manager/project.py +++ b/cvat/apps/dataset_manager/project.py @@ -85,8 +85,6 @@ def split_name(file): "use_cache": False, "use_zip_chunks": True, "image_quality": 70, - "consensus_job_per_segment": 0, - "agreement_score_threshold": 0, }) data_serializer.is_valid(raise_exception=True) db_data = data_serializer.save() diff --git a/cvat/apps/engine/admin.py b/cvat/apps/engine/admin.py index 1b96cfa27d0..05e4b40a0f9 100644 --- a/cvat/apps/engine/admin.py +++ b/cvat/apps/engine/admin.py @@ -59,7 +59,7 @@ def has_add_permission(self, _request, obj): class DataAdmin(admin.ModelAdmin): model = Data - fields = ('chunk_size', 'size', 'image_quality', 'start_frame', 'stop_frame', 'frame_filter', 'compressed_chunk_type', 'original_chunk_type', 'consensus_job_per_segment', 'agreement_score_threshold') + fields = ('chunk_size', 'size', 'image_quality', 'start_frame', 'stop_frame', 'frame_filter', 'compressed_chunk_type', 'original_chunk_type') readonly_fields = fields autocomplete_fields = ('cloud_storage', ) diff --git a/cvat/apps/engine/migrations/0089_remove_data_agreement_score_threshold_and_more.py b/cvat/apps/engine/migrations/0089_remove_data_agreement_score_threshold_and_more.py new file mode 100644 index 00000000000..942bfb41cff --- /dev/null +++ b/cvat/apps/engine/migrations/0089_remove_data_agreement_score_threshold_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.11 on 2024-06-06 13:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0088_rename_source_job_id_job_parent_job_id_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="data", + name="agreement_score_threshold", + ), + migrations.RemoveField( + model_name="data", + name="consensus_job_per_segment", + ), + migrations.AddField( + model_name="task", + name="agreement_score_threshold", + field=models.FloatField(blank=True, default=0), + ), + migrations.AddField( + model_name="task", + name="consensus_job_per_segment", + field=models.IntegerField(blank=True, default=0), + ), + ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 83b6ced483a..bb01bf3c0dc 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -232,8 +232,6 @@ 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) - consensus_job_per_segment = models.IntegerField(default=0, blank=True) - agreement_score_threshold = models.FloatField(default=0, blank=True) class Meta: default_permissions = () @@ -420,6 +418,8 @@ class Task(TimestampedModel): blank=True, on_delete=models.SET_NULL, related_name='+') target_storage = models.ForeignKey('Storage', null=True, default=None, blank=True, on_delete=models.SET_NULL, related_name='+') + consensus_job_per_segment = models.IntegerField(default=0, blank=True) + agreement_score_threshold = models.FloatField(default=0, blank=True) # Extend default permission model class Meta: diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 900eef15dfe..704dd14e660 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -965,14 +965,6 @@ class DataSerializer(serializers.ModelSerializer): pass the list of file names in the required order. """.format(models.SortingMethod.PREDEFINED)) ) - consensus_job_per_segment = serializers.IntegerField(default=0, - help_text=textwrap.dedent("""\ - Number of Consensus Jobs for each Normal Job. - """)) - agreement_score_threshold = serializers.FloatField(default=0, - help_text=textwrap.dedent("""\ - Agreement Score Threshold, for merging consensus jobs. - """)) class Meta: model = models.Data @@ -982,8 +974,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', 'consensus_job_per_segment', - 'agreement_score_threshold', + 'job_file_mapping', 'upload_file_order', ) extra_kwargs = { 'chunk_size': { 'help_text': "Maximum number of frames per chunk" }, @@ -1106,8 +1097,8 @@ 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='*') - consensus_job_per_segment = serializers.ReadOnlyField(source='data.consensus_job_per_segment', required=False) - agreement_score_threshold = serializers.FloatField(source='data.agreement_score_threshold', required=False) + consensus_job_per_segment = serializers.ReadOnlyField(required=False) + agreement_score_threshold = serializers.FloatField(required=False) class Meta: model = models.Task @@ -1198,7 +1189,7 @@ def update(self, instance, validated_data): instance.bug_tracker) instance.subset = validated_data.get('subset', instance.subset) labels = validated_data.get('label_set', []) - instance.data.agreement_score_threshold = validated_data.get('agreement_score_threshold', instance.data.agreement_score_threshold) + instance.agreement_score_threshold = validated_data.get('agreement_score_threshold', instance.agreement_score_threshold) if instance.project_id is None: LabelSerializer.update_labels(labels, parent_instance=instance) @@ -1459,8 +1450,6 @@ class DataMetaReadSerializer(serializers.ModelSerializer): help_text=textwrap.dedent("""\ A list of valid frame ids. The None value means all frames are included. """)) - consensus_job_per_segment = serializers.IntegerField(default=0) - agreement_score_threshold = serializers.FloatField(default=0) class Meta: model = models.Data @@ -1474,8 +1463,6 @@ class Meta: 'frames', 'deleted_frames', 'included_frames', - 'consensus_job_per_segment', - 'agreement_score_threshold', ) read_only_fields = fields extra_kwargs = { diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 5031d8ac818..141601ffb2b 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -173,7 +173,7 @@ def _save_task_to_db(db_task: models.Task, *, job_file_mapping: Optional[JobFile db_job.make_dirs() # consensus jobs use the same `db_segment` as the normal job, thus data not duplicated in backups, exports - for _ in range(db_task.data.consensus_job_per_segment): + for _ in range(db_task.consensus_job_per_segment): consensus_db_job = models.Job(segment=db_segment, parent_job_id=db_job.id) consensus_db_job.save() consensus_db_job.make_dirs() From f04c6bd1c46adf2791930c57c53f3e17c576a1e4 Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 6 Jun 2024 20:45:15 +0530 Subject: [PATCH 016/301] setting the Task parameters consensus_job_per_segment and agreement_score_threshold which are set through extra configuration when creating task --- cvat/apps/engine/task.py | 2 ++ cvat/apps/engine/views.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 141601ffb2b..87947b7a256 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -908,6 +908,8 @@ def _update_status(msg: str) -> None: ) db_task.mode = task_mode + db_task.consensus_job_per_segment = int(data['consensus_job_per_segment']) + db_task.agreement_score_threshold = float(data['agreement_score_threshold']) 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 diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 2ef73e29c6b..23378542491 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -1058,6 +1058,8 @@ def _handle_upload_data(request): data['use_zip_chunks'] = serializer.validated_data['use_zip_chunks'] data['use_cache'] = serializer.validated_data['use_cache'] data['copy_data'] = serializer.validated_data['copy_data'] + data['consensus_job_per_segment'] = request.data['consensus_job_per_segment'] + data['agreement_score_threshold'] = request.data['agreement_score_threshold'] if data['use_cache']: self._object.data.storage_method = StorageMethodChoice.CACHE From 0a43b084269bfbd0581b54ef0a244ce0a3e1745a Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 6 Jun 2024 20:56:26 +0530 Subject: [PATCH 017/301] added consensus_job_per_segment to TaskWriteSerializer so it's value can be set through API call also when creating task initially --- cvat/apps/engine/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 704dd14e660..17e2d74d9c8 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1123,13 +1123,14 @@ class TaskWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): project_id = serializers.IntegerField(required=False, allow_null=True) target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) + consensus_job_per_segment = serializers.IntegerField(required=False) agreement_score_threshold = serializers.FloatField(required=False, allow_null=True) 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', 'agreement_score_threshold' + 'target_storage', 'source_storage', 'consensus_job_per_segment', 'agreement_score_threshold' ) write_once_fields = ('overlap', 'segment_size') From 75b2a8013cafdd34fbca41ec6cb834955c4d8d63 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 7 Jun 2024 03:13:00 +0530 Subject: [PATCH 018/301] name of job changes based on whether it's consensus or normal job in multiple annotator mode --- cvat-ui/src/components/job-item/job-item.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cvat-ui/src/components/job-item/job-item.tsx b/cvat-ui/src/components/job-item/job-item.tsx index 124c01c2af8..22059bd9f3b 100644 --- a/cvat-ui/src/components/job-item/job-item.tsx +++ b/cvat-ui/src/components/job-item/job-item.tsx @@ -113,6 +113,11 @@ function JobItem(props: Props): JSX.Element { } const frameCountPercent = ((job.frameCount / (task.size || 1)) * 100).toFixed(0); const frameCountPercentRepresentation = frameCountPercent === '0' ? '<1' : frameCountPercent; + let jobName = `Job #${job.id}`; + if (task.consensusJobPerSegment && job.type !== JobType.GROUND_TRUTH) { + jobName = job.parentJobId === null ? `Normal Job #${job.id}` : `Consensus Job #${job.id}`; + } + return ( @@ -120,7 +125,9 @@ function JobItem(props: Props): JSX.Element { - {`Job #${job.id}`} + + { jobName } + { job.type === JobType.GROUND_TRUTH && ( From 6b1725131ded353c84a402834eb42e8b8f91f5e4 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 7 Jun 2024 03:46:33 +0530 Subject: [PATCH 019/301] added collapsing consensus jobs while viewing specific task --- cvat-ui/src/components/job-item/job-item.tsx | 25 +++++++++++++++++++ cvat-ui/src/components/task-page/job-list.tsx | 3 +++ 2 files changed, 28 insertions(+) diff --git a/cvat-ui/src/components/job-item/job-item.tsx b/cvat-ui/src/components/job-item/job-item.tsx index 22059bd9f3b..2b44f8e457d 100644 --- a/cvat-ui/src/components/job-item/job-item.tsx +++ b/cvat-ui/src/components/job-item/job-item.tsx @@ -27,6 +27,7 @@ import { import { useIsMounted } from 'utils/hooks'; import UserSelector from 'components/task-page/user-selector'; import CVATTooltip from 'components/common/cvat-tooltip'; +import { Collapse } from 'antd'; import JobActionsMenu from './job-actions-menu'; interface Props { @@ -118,6 +119,14 @@ function JobItem(props: Props): JSX.Element { jobName = job.parentJobId === null ? `Normal Job #${job.id}` : `Consensus Job #${job.id}`; } + let consensusJob: Job[] = []; + if (task.consensusJobPerSegment) { + consensusJob = task.jobs.filter((eachJob: Job) => eachJob.parentJobId === id); + } + const consensusJobView: React.JSX.Element[] = consensusJob.map((eachJob: Job) => ( + + )); + return ( @@ -256,6 +265,22 @@ function JobItem(props: Props): JSX.Element { > + {consensusJob.length > 0 && + ( + + {`${consensusJob.length} Consensus Jobs`} + , + children: ( + consensusJobView + ), + }]} + /> + )} ); diff --git a/cvat-ui/src/components/task-page/job-list.tsx b/cvat-ui/src/components/task-page/job-list.tsx index d75a4c9062d..70be14339dd 100644 --- a/cvat-ui/src/components/task-page/job-list.tsx +++ b/cvat-ui/src/components/task-page/job-list.tsx @@ -53,6 +53,9 @@ function setUpJobsList(jobs: Job[], query: JobsQuery): Job[] { result = result.filter((job, index) => jsonLogic.apply(filter, converted[index])); } + // primarily only normal jobs should be shown + result = result.filter((job) => job.parentJobId === null); + return result; } From 5601d40493aec919d3db8c6f417c51931432b388 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 7 Jun 2024 03:48:19 +0530 Subject: [PATCH 020/301] task name has the information about annotation mode --- cvat-ui/src/components/task-page/details.tsx | 30 +++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/cvat-ui/src/components/task-page/details.tsx b/cvat-ui/src/components/task-page/details.tsx index 5734f076651..e925b4091f1 100644 --- a/cvat-ui/src/components/task-page/details.tsx +++ b/cvat-ui/src/components/task-page/details.tsx @@ -68,6 +68,7 @@ const core = getCore(); interface State { name: string; subset: string; + consensusJobPerSegment: number; } type Props = DispatchToProps & StateToProps & OwnProps; @@ -79,6 +80,7 @@ class DetailsComponent extends React.PureComponent { this.state = { name: taskInstance.name, subset: taskInstance.subset, + consensusJobPerSegment: taskInstance.consensusJobPerSegment, }; } @@ -93,8 +95,9 @@ class DetailsComponent extends React.PureComponent { } private renderTaskName(): JSX.Element { - const { name } = this.state; + const { name, consensusJobPerSegment } = this.state; const { task: taskInstance, onUpdateTask } = this.props; + const taskName = name + (consensusJobPerSegment > 0 ? ' (Consensus Based Annotation)' : ''); return ( { }} className='cvat-text-color cvat-task-name' > - {name} + { taskName } ); } @@ -238,15 +241,20 @@ class DetailsComponent extends React.PureComponent { }} /> - - { - taskInstance.agreementScoreThreshold = value; - onUpdateTask(taskInstance); - }} - /> - + { + taskInstance.consensusJobPerSegment > 0 && ( + + { + taskInstance.agreementScoreThreshold = value; + onUpdateTask(taskInstance); + }} + /> + + ) + } + Date: Fri, 7 Jun 2024 04:24:40 +0530 Subject: [PATCH 021/301] corrected how consensus_job_per_segment and agreement_score_threshold passed from frontend to backend --- cvat-core/src/session-implementation.ts | 10 ++++++++-- cvat/apps/engine/task.py | 2 -- cvat/apps/engine/views.py | 2 -- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index b1fdd9a4f66..1b2c2af3423 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -476,6 +476,14 @@ export function implementTask(Task) { taskSpec.source_storage = this.sourceStorage.toJSON(); } + if (this.consensusJobPerSegment) { + taskSpec.consensus_job_per_segment = this.consensusJobPerSegment; + } + + if (this.agreementScoreThreshold) { + taskSpec.agreement_score_threshold = this.agreementScoreThreshold; + } + const taskDataSpec = { client_files: this.clientFiles, server_files: this.serverFiles, @@ -484,8 +492,6 @@ export function implementTask(Task) { use_zip_chunks: this.useZipChunks, use_cache: this.useCache, sorting_method: this.sortingMethod, - consensus_job_per_segment: this.consensusJobPerSegment, - agreement_score_threshold: this.agreementScoreThreshold, ...(typeof this.startFrame !== 'undefined' ? { start_frame: this.startFrame } : {}), ...(typeof this.stopFrame !== 'undefined' ? { stop_frame: this.stopFrame } : {}), ...(typeof this.frameFilter !== 'undefined' ? { frame_filter: this.frameFilter } : {}), diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 87947b7a256..141601ffb2b 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -908,8 +908,6 @@ def _update_status(msg: str) -> None: ) db_task.mode = task_mode - db_task.consensus_job_per_segment = int(data['consensus_job_per_segment']) - db_task.agreement_score_threshold = float(data['agreement_score_threshold']) 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 diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 23378542491..2ef73e29c6b 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -1058,8 +1058,6 @@ def _handle_upload_data(request): data['use_zip_chunks'] = serializer.validated_data['use_zip_chunks'] data['use_cache'] = serializer.validated_data['use_cache'] data['copy_data'] = serializer.validated_data['copy_data'] - data['consensus_job_per_segment'] = request.data['consensus_job_per_segment'] - data['agreement_score_threshold'] = request.data['agreement_score_threshold'] if data['use_cache']: self._object.data.storage_method = StorageMethodChoice.CACHE From f52ece1cd59b31eb8d7c6f3646ff6940a376a953 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 7 Jun 2024 20:43:19 +0530 Subject: [PATCH 022/301] consensus_job_per_segment and agreement_score_threshold included in the task bacup --- cvat/apps/engine/backup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index b7808bcf5b7..02b0bee1438 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -183,6 +183,8 @@ def _prepare_task_meta(self, task): 'status', 'subset', 'labels', + 'consensus_job_per_segment', + 'agreement_score_threshold', } return self._prepare_meta(allowed_fields, task) @@ -201,8 +203,6 @@ def _prepare_data_meta(self, data): 'deleted_frames', 'custom_segments', 'job_file_mapping', - 'consensus_job_per_segment', - 'agreement_score_threshold', } self._prepare_meta(allowed_fields, data) From 9e3f346c3d61b5290cc58faf00e1fdbd3901662a Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 9 Jun 2024 02:40:44 +0530 Subject: [PATCH 023/301] Comment of while exporting task dataset only normal jobs data is exported not gt or consensus jobs --- cvat/apps/dataset_manager/task.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 01fb41a66d7..9b983586bb5 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -729,6 +729,7 @@ def __init__(self, pk): ).get(id=pk) # Postgres doesn't guarantee an order by default without explicit order_by + # Only select normal jobs, not ground truth or consensus jobs self.db_jobs = models.Job.objects.select_related("segment").filter( segment__task_id=pk, type=models.JobType.ANNOTATION.value, ).order_by('id') @@ -784,7 +785,7 @@ def init_from_db(self): self.reset() for db_job in self.db_jobs: - if db_job.type != models.JobType.ANNOTATION and db_job.parent_job_id is not None: + if db_job.type != models.JobType.ANNOTATION or db_job.parent_job_id is not None: continue annotation = JobAnnotation(db_job.id, is_prefetched=True) From 034528c521029ce39774885194347c36999c9225 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 9 Jun 2024 02:41:52 +0530 Subject: [PATCH 024/301] added a parent_job_id as a filter field to quickly get consensus jobs through the parent_job_id --- cvat/apps/engine/views.py | 2 +- cvat/schema.yml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 2ef73e29c6b..bebd083d6d6 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -1595,7 +1595,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, mixins.CreateMo iam_organization_field = 'segment__task__organization' search_fields = ('task_name', 'project_name', 'assignee', 'state', 'stage') filter_fields = list(search_fields) + [ - 'id', 'task_id', 'project_id', 'updated_date', 'dimension', 'type' + 'id', 'task_id', 'project_id', 'updated_date', 'dimension', 'type', 'parent_job_id', ] simple_filters = list(set(filter_fields) - {'id', 'updated_date'}) ordering_fields = list(filter_fields) diff --git a/cvat/schema.yml b/cvat/schema.yml index 833f14d091d..a18a7c0daaf 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1891,6 +1891,11 @@ paths: enum: - annotation - ground_truth + - name: parent_job_id + in: query + description: A simple equality filter for the parent_job_id field + schema: + type: integer tags: - jobs security: From b09d7b06b53a43c97e487f00719cd99000b8cb29 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 9 Jun 2024 03:00:44 +0530 Subject: [PATCH 025/301] added server side validation for consensus configurations of a task --- cvat/apps/engine/serializers.py | 3 +++ cvat/apps/engine/task.py | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 17e2d74d9c8..e236707ae12 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1195,6 +1195,9 @@ def update(self, instance, validated_data): if instance.project_id is None: LabelSerializer.update_labels(labels, parent_instance=instance) + if instance.agreement_score_threshold < 0 or instance.agreement_score_threshold > 1: + raise serializers.ValidationError('Agreement score threshold must be in [0, 1]') + validated_project_id = validated_data.get('project_id') if validated_project_id is not None and validated_project_id != instance.project_id: project = models.Project.objects.get(id=validated_project_id) diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 141601ffb2b..4020a126583 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -367,6 +367,16 @@ def _validate_scheme(url): if parsed_url.scheme not in ALLOWED_SCHEMES: raise ValueError('Unsupported URL scheme: {}. Only http and https are supported'.format(parsed_url.scheme)) +def _validate_consensus_configuration(db_task: models.Task, data: Dict[str, Any]) -> None: + consensus_job_per_segment = data.get('consensus_job_per_segment', None) + agreement_score_threshold = data.get('agreement_score_threshold', None) + + if agreement_score_threshold < 0 or agreement_score_threshold > 1: + raise ValidationError("Agreement score threshold should be in the range [0, 1]") + + if consensus_job_per_segment == 1: + raise ValidationError("Consensus job per segment should not be 1") + def _download_data(urls, upload_dir): job = rq.get_current_job() local_files = {} @@ -518,6 +528,8 @@ def _create_thread( job_file_mapping = _validate_job_file_mapping(db_task, data) + _validate_consensus_configuration(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 From fdca0accd95f7cc9b2b34321c8c6fa0ad7edc0c9 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 9 Jun 2024 03:21:07 +0530 Subject: [PATCH 026/301] consensus_job_per_segment can't be edited once task is created --- cvat/apps/engine/serializers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index e236707ae12..53f8bdd20d9 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1123,14 +1123,13 @@ class TaskWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): project_id = serializers.IntegerField(required=False, allow_null=True) target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) - consensus_job_per_segment = serializers.IntegerField(required=False) agreement_score_threshold = serializers.FloatField(required=False, allow_null=True) 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', 'consensus_job_per_segment', 'agreement_score_threshold' + 'target_storage', 'source_storage', 'agreement_score_threshold' ) write_once_fields = ('overlap', 'segment_size') From 43209bf8ba605a56a0b737d5eb2d8c26b3cb9c3c Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 12 Jun 2024 16:38:22 +0530 Subject: [PATCH 027/301] moved consensus configuration validation to TaskWrite Serializer --- cvat/apps/engine/serializers.py | 15 +++++++++++++++ cvat/apps/engine/task.py | 10 ---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 53f8bdd20d9..fb575713c07 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1314,6 +1314,21 @@ def validate(self, attrs): if sublabels != target_project_sublabel_names.get(label): raise serializers.ValidationError('All task or project label names must be mapped to the target project') + consensus_job_per_segment = attrs.get('consensus_job_per_segment', None) + agreement_score_threshold = attrs.get('agreement_score_threshold', None) + + if consensus_job_per_segment is None: + raise serializers.ValidationError("Consensus job per segment can't be None") + + if agreement_score_threshold is None: + raise serializers.ValidationError("Agreement score threshold can't be None") + + if agreement_score_threshold < 0 or agreement_score_threshold > 1: + raise serializers.ValidationError("Agreement score threshold should be in the range [0, 1]") + + if consensus_job_per_segment == 1 or consensus_job_per_segment < 0: + raise serializers.ValidationError("Consensus job per segment should be greater than or equal to 0 and not 1") + return attrs class ProjectReadSerializer(serializers.ModelSerializer): diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 4020a126583..ded9deed827 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -367,15 +367,6 @@ def _validate_scheme(url): if parsed_url.scheme not in ALLOWED_SCHEMES: raise ValueError('Unsupported URL scheme: {}. Only http and https are supported'.format(parsed_url.scheme)) -def _validate_consensus_configuration(db_task: models.Task, data: Dict[str, Any]) -> None: - consensus_job_per_segment = data.get('consensus_job_per_segment', None) - agreement_score_threshold = data.get('agreement_score_threshold', None) - - if agreement_score_threshold < 0 or agreement_score_threshold > 1: - raise ValidationError("Agreement score threshold should be in the range [0, 1]") - - if consensus_job_per_segment == 1: - raise ValidationError("Consensus job per segment should not be 1") def _download_data(urls, upload_dir): job = rq.get_current_job() @@ -528,7 +519,6 @@ def _create_thread( job_file_mapping = _validate_job_file_mapping(db_task, data) - _validate_consensus_configuration(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 From 5900ec4cba765cb43fa3d09e2e2046f36cf45169 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 12 Jun 2024 16:38:39 +0530 Subject: [PATCH 028/301] added consensus_job_per_segment to TaskWriteSerializer --- cvat/apps/engine/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index fb575713c07..0c99097cbe1 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1123,13 +1123,14 @@ class TaskWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): project_id = serializers.IntegerField(required=False, allow_null=True) target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) + consensus_job_per_segment = serializers.IntegerField(required=False) agreement_score_threshold = serializers.FloatField(required=False, allow_null=True) 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', 'agreement_score_threshold' + 'target_storage', 'source_storage', 'consensus_job_per_segment', 'agreement_score_threshold' ) write_once_fields = ('overlap', 'segment_size') From 424f70c30fe774c8033420f50c9d9011996bea91 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 12 Jun 2024 16:43:54 +0530 Subject: [PATCH 029/301] while viewing task consensus jobs now shown in ascending order of their job id --- cvat-ui/src/components/job-item/job-item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat-ui/src/components/job-item/job-item.tsx b/cvat-ui/src/components/job-item/job-item.tsx index 2b44f8e457d..0db430958d5 100644 --- a/cvat-ui/src/components/job-item/job-item.tsx +++ b/cvat-ui/src/components/job-item/job-item.tsx @@ -121,7 +121,7 @@ function JobItem(props: Props): JSX.Element { let consensusJob: Job[] = []; if (task.consensusJobPerSegment) { - consensusJob = task.jobs.filter((eachJob: Job) => eachJob.parentJobId === id); + consensusJob = task.jobs.filter((eachJob: Job) => eachJob.parentJobId === id).reverse(); } const consensusJobView: React.JSX.Element[] = consensusJob.map((eachJob: Job) => ( From 67b3d00e8dde119a9aa03c0031c5aa5495b4e180 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 18 Jun 2024 06:21:01 +0530 Subject: [PATCH 030/301] created consensus app for handling merging --- cvat/apps/consensus/__init__.py | 0 cvat/apps/consensus/admin.py | 3 + cvat/apps/consensus/apps.py | 13 +++ cvat/apps/consensus/merge_consensus_jobs.py | 99 +++++++++++++++++++++ cvat/apps/consensus/migrations/__init__.py | 0 cvat/apps/consensus/models.py | 11 +++ cvat/apps/consensus/tests.py | 3 + cvat/apps/consensus/views.py | 18 ++++ 8 files changed, 147 insertions(+) create mode 100644 cvat/apps/consensus/__init__.py create mode 100644 cvat/apps/consensus/admin.py create mode 100644 cvat/apps/consensus/apps.py create mode 100644 cvat/apps/consensus/merge_consensus_jobs.py create mode 100644 cvat/apps/consensus/migrations/__init__.py create mode 100644 cvat/apps/consensus/models.py create mode 100644 cvat/apps/consensus/tests.py create mode 100644 cvat/apps/consensus/views.py diff --git a/cvat/apps/consensus/__init__.py b/cvat/apps/consensus/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cvat/apps/consensus/admin.py b/cvat/apps/consensus/admin.py new file mode 100644 index 00000000000..8c38f3f3dad --- /dev/null +++ b/cvat/apps/consensus/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/cvat/apps/consensus/apps.py b/cvat/apps/consensus/apps.py new file mode 100644 index 00000000000..446107f5800 --- /dev/null +++ b/cvat/apps/consensus/apps.py @@ -0,0 +1,13 @@ +# Copyright (C) 2023-2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from django.apps import AppConfig + + +class ConsensusConfig(AppConfig): + name = "cvat.apps.consensus" + + def ready(self) -> None: + from cvat.apps.iam.permissions import load_app_permissions + load_app_permissions(self) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py new file mode 100644 index 00000000000..ae5f152f636 --- /dev/null +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -0,0 +1,99 @@ +from typing import List, Dict +import datumaro as dm +import django_rq +from django.conf import settings +from datumaro.components.operations import IntersectMerge +from cvat.apps.quality_control.quality_reports import JobDataProvider +from cvat.apps.engine.models import Job, JobType +from cvat.apps.dataset_manager.bindings import import_dm_annotations +from rest_framework import status +from rest_framework.response import Response +from cvat.apps.dataset_manager.task import patch_job_data, PatchAction +from cvat.apps.engine.utils import get_rq_lock_by_user, get_rq_job_meta, define_dependent_job, process_failed_job +from cvat.apps.engine.serializers import RqIdSerializer +from django.utils import timezone + +def get_consensus_jobs(task_id: int): + jobs = {} # parent_job_id -> [consensus_job_id] + for job in Job.objects.select_related("segment").filter(segment__task_id=task_id, type=JobType.ANNOTATION.value).order_by('id'): + if job.parent_job_id is not None: + if job.parent_job_id not in jobs: + jobs[job.parent_job_id] = [] + jobs[job.parent_job_id].append(job.id) + return jobs + +def get_annotations(job_id: int): + return JobDataProvider(job_id).dm_dataset + +def _merge_consensus_jobs(task_id: int): + jobs = get_consensus_jobs(task_id) + merger = IntersectMerge() + + for parent_job_id, job_ids in jobs.items(): + consensus_dataset = list(map(get_annotations, job_ids)) + + merged_dataset: dm.Dataset = merger(consensus_dataset) + + # check if the merged dataset has annotations + for item in merged_dataset: + if not item.annotations: + return 400 + + # delete the existing annotations in the job + patch_job_data(parent_job_id, None, PatchAction.DELETE) + """ + if we don't delete exising annotations, the imported annotations + will be appended to the existing annotations, and thus updated annotation + would have both exisiting + imported annotations, but we only want the + imported annotations + """ + + parent_job = JobDataProvider(parent_job_id) + + # imports the annotations in the this `parent_job.job_data` instance + import_dm_annotations(merged_dataset, parent_job.job_data) + + # updates the annotations in the job + patch_job_data(parent_job_id, parent_job.job_data.data.serialize(), PatchAction.UPDATE) + return 201 + + +def merge_task(task, request): + queue_name=settings.CVAT_QUEUES.CONSENSUS.value + queue = django_rq.get_queue(queue_name) + # so a user doesn't create requests to merge same task multiple times + rq_id = rq_id = request.data.get('rq_id', f"merge_consensus:task.id{task.id}-by-{request.user}") + rq_job = queue.fetch_job(rq_id) + user_id = request.user.id + last_instance_update_time = timezone.localtime(task.updated_date) + + if rq_job: + if rq_job.is_finished: + returned_data = rq_job.return_value() + rq_job.delete() + return Response(status=status.HTTP_201_CREATED) if returned_data == 201 else Response(status=status.HTTP_400_BAD_REQUEST) + elif rq_job.is_failed: + exc_info = process_failed_job(rq_job) + return Response(data=exc_info, + status=status.HTTP_500_INTERNAL_SERVER_ERROR) + else: + # rq_job is in queued stage or might be running + return Response(status=status.HTTP_202_ACCEPTED) + # return Response(serializer.data, status=status.HTTP_202_ACCEPTED) + + func = _merge_consensus_jobs + func_args = [task.id] + + with get_rq_lock_by_user(queue, user_id): + queue.enqueue_call( + func=func, + args=func_args, + job_id=rq_id, + meta=get_rq_job_meta(request=request, db_obj=task), + depends_on=define_dependent_job(queue, user_id), + ) + + return Response(status=status.HTTP_202_ACCEPTED) + # serializer = RqIdSerializer(data={'rq_id': rq_id}) + # serializer.is_valid(raise_exception=True) + # return Response(serializer.data, status=status.HTTP_202_ACCEPTED) diff --git a/cvat/apps/consensus/migrations/__init__.py b/cvat/apps/consensus/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cvat/apps/consensus/models.py b/cvat/apps/consensus/models.py new file mode 100644 index 00000000000..f8e14701170 --- /dev/null +++ b/cvat/apps/consensus/models.py @@ -0,0 +1,11 @@ +from django.db import models +from cvat.apps.engine.models import Job, ShapeType, Task +from django.db import models + + +class MergeReport(models.Model): + task = models.ForeignKey( + Task, on_delete=models.CASCADE, related_name="merge_reports", null=True, blank=True + ) + created_date = models.DateTimeField(auto_now_add=True) + data = models.JSONField() \ No newline at end of file diff --git a/cvat/apps/consensus/tests.py b/cvat/apps/consensus/tests.py new file mode 100644 index 00000000000..7ce503c2dd9 --- /dev/null +++ b/cvat/apps/consensus/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/cvat/apps/consensus/views.py b/cvat/apps/consensus/views.py new file mode 100644 index 00000000000..4f732f801e2 --- /dev/null +++ b/cvat/apps/consensus/views.py @@ -0,0 +1,18 @@ +from django.shortcuts import render + +# Create your views here. +""" +engine> views.py> TaskViewSet + +For now that's fine, but it should return `rq_id` + +In this views.py we can get details on merge report. + +storing merge report as `.json` like analytics report or quality report [prefered] +like a string only a parameter model + +or somewhat like storing report attributes. + +/agreegate/ => list of merge reports + +""" \ No newline at end of file From afe5332e86501cab4ec6a70585f7ae1db58d77ed Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 18 Jun 2024 06:22:25 +0530 Subject: [PATCH 031/301] created agreegate endpoint, to agreegate consensus jobs in the task --- cvat/apps/engine/views.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index bebd083d6d6..d037ec2d02c 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -88,6 +88,7 @@ CommentPermission, IssuePermission, JobPermission, LabelPermission, ProjectPermission, TaskPermission, UserPermission) from cvat.apps.engine.view_utils import tus_chunk_action +from cvat.apps.consensus.merge_consensus_jobs import merge_task slogger = ServerLogManager(__name__) @@ -889,6 +890,22 @@ def export_backup(self, request, pk=None): ) return self.serialize(request, backup.export) + @extend_schema(summary="Aggregate data of a task", + responses={ + '201': OpenApiResponse(description='Consensus Jobs Agreegated'), + '202': OpenApiResponse(description='Agreegation of Consensus Jobs started'), + '400': OpenApiResponse(description='Agreegating a task without data is not allowed'), + }, + ) + @action(methods=['GET'], detail=True, url_path=r'agreegate/?$') + def aggregate(self, request, pk=None): + task = self.get_object() + + return merge_task( + task, + request + ) + @transaction.atomic def perform_update(self, serializer): instance = serializer.instance From fcf99ff49e79dfa1fca889f4d98f84966bf7228f Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 18 Jun 2024 06:23:49 +0530 Subject: [PATCH 032/301] added Python RQ for consensus app --- .vscode/launch.json | 25 ++++++++++++++++++++++++- cvat/settings/base.py | 5 +++++ docker-compose.external_db.yml | 1 + docker-compose.yml | 16 ++++++++++++++++ supervisord/worker.consensus.conf | 27 +++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 supervisord/worker.consensus.conf diff --git a/.vscode/launch.json b/.vscode/launch.json index 38d3db0518e..8a794d0c264 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -360,6 +360,28 @@ }, "console": "internalConsole" }, + { + "name": "server: RQ - consensus", + "type": "debugpy", + "request": "launch", + "stopOnEntry": false, + "justMyCode": false, + "python": "${command:python.interpreterPath}", + "program": "${workspaceRoot}/manage.py", + "args": [ + "rqworker", + "consensus", + "--worker-class", + "cvat.rqworker.SimpleWorker" + ], + "django": true, + "cwd": "${workspaceFolder}", + "env": { + "DJANGO_LOG_SERVER_HOST": "localhost", + "DJANGO_LOG_SERVER_PORT": "8282" + }, + "console": "internalConsole" + }, { "name": "server: migrate", "type": "debugpy", @@ -537,7 +559,8 @@ "server: RQ - scheduler", "server: RQ - quality reports", "server: RQ - analytics reports", - "server: RQ - cleaning" + "server: RQ - cleaning", + "server: RQ - consensus", ] } ] diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 46b6a075b1f..689ba4a04c7 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -273,6 +273,7 @@ class CVAT_QUEUES(Enum): QUALITY_REPORTS = 'quality_reports' ANALYTICS_REPORTS = 'analytics_reports' CLEANING = 'cleaning' + CONSENSUS = 'consensus' redis_inmem_host = os.getenv('CVAT_REDIS_INMEM_HOST', 'localhost') redis_inmem_port = os.getenv('CVAT_REDIS_INMEM_PORT', 6379) @@ -318,6 +319,10 @@ class CVAT_QUEUES(Enum): **shared_queue_settings, 'DEFAULT_TIMEOUT': '1h', }, + CVAT_QUEUES.CONSENSUS.value: { + **shared_queue_settings, + 'DEFAULT_TIMEOUT': '1h', + }, } NUCLIO = { diff --git a/docker-compose.external_db.yml b/docker-compose.external_db.yml index decd1e9ed14..6e56247c43f 100644 --- a/docker-compose.external_db.yml +++ b/docker-compose.external_db.yml @@ -27,6 +27,7 @@ services: cvat_worker_import: *backend-settings cvat_worker_quality_reports: *backend-settings cvat_worker_webhooks: *backend-settings + cvat_worker_consensus: *backend-settings secrets: postgres_password: diff --git a/docker-compose.yml b/docker-compose.yml index 98bf519c20d..c9dea1c60ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -217,6 +217,22 @@ services: networks: - cvat + cvat_worker_consensus: + container_name: cvat_worker_consensus + image: cvat/server:${CVAT_VERSION:-dev} + restart: always + depends_on: *backend-deps + environment: + <<: *backend-env + NUMPROCS: 2 + command: run worker.consensus + volumes: + - cvat_data:/home/django/data + - cvat_keys:/home/django/keys + - cvat_logs:/home/django/logs + networks: + - cvat + cvat_ui: container_name: cvat_ui image: cvat/ui:${CVAT_VERSION:-dev} diff --git a/supervisord/worker.consensus.conf b/supervisord/worker.consensus.conf new file mode 100644 index 00000000000..7f07bb0db1f --- /dev/null +++ b/supervisord/worker.consensus.conf @@ -0,0 +1,27 @@ +# [unix_http_server] +# file = /tmp/supervisord/supervisor.sock + +# [supervisorctl] +# serverurl = unix:///tmp/supervisord/supervisor.sock + + +# [rpcinterface:supervisor] +# supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +# [supervisord] +# nodaemon=true +# logfile=%(ENV_HOME)s/logs/supervisord.log ; supervisord log file +# logfile_maxbytes=50MB ; maximum size of logfile before rotation +# logfile_backups=10 ; number of backed up logfiles +# loglevel=debug ; info, debug, warn, trace +# pidfile=/tmp/supervisord/supervisord.pid ; pidfile location +# childlogdir=%(ENV_HOME)s/logs/ ; where child log files will live + +# [program:rqworker-consensus] +# command=%(ENV_HOME)s/wait_for_deps.sh +# python3 %(ENV_HOME)s/manage.py rqworker -v 3 consensus +# --worker-class cvat.rqworker.DefaultWorker +# environment=VECTOR_EVENT_HANDLER="SynchronousLogstashHandler",CVAT_POSTGRES_APPLICATION_NAME="cvat:worker:consensus" +# numprocs=%(ENV_NUMPROCS)s +# process_name=%(program_name)s-%(process_num)d +# autorestart=true From 9ae06c34ec2950b83e3ff92c68d93c10d48b83cf Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 18 Jun 2024 06:27:29 +0530 Subject: [PATCH 033/301] added mergeConsensusJobs function to Task session in the frontend, which calls /agreegate/ endpoint --- cvat-core/src/server-proxy.ts | 25 +++++++++++++++++++++++++ cvat-core/src/session-implementation.ts | 5 +++++ cvat-core/src/session.ts | 5 +++++ 3 files changed, 35 insertions(+) diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index 1ed12056e2e..166e85bbb18 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -810,6 +810,30 @@ async function deleteTask(id: number, organizationID: string | null = null): Pro } } +async function mergeConsensusJobs(id: number): Promise { + const { backendAPI } = config; + const url = `${backendAPI}/tasks/${id}/agreegate`; + + return new Promise((resolve, reject) => { + async function request() { + try { + const response = await Axios.get(url); + const { status } = response; + if (status === 202) { + setTimeout(request, 3000); + } else if (status === 201) { + resolve(); + } else { + reject(generateError(response)); + } + } catch (errorData) { + reject(generateError(errorData)); + } + } + setTimeout(request); + }); +} + async function getLabels(filter: { job_id?: number, task_id?: number, @@ -2562,6 +2586,7 @@ export default Object.freeze({ getPreview: getPreview('tasks'), backup: backupTask, restore: restoreTask, + mergeConsensusJobs, }), labels: Object.freeze({ diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 1b2c2af3423..4f0e4c9d95e 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -530,6 +530,11 @@ export function implementTask(Task) { return result; }; + Task.prototype.mergeConsensusJobs.implementation = async function () { + const result = await serverProxy.tasks.mergeConsensusJobs(this.id); + return result; + }; + Task.prototype.issues.implementation = async function () { const result = await serverProxy.issues.get({ task_id: this.id }); return result.map((issue) => new Issue(issue)); diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 6f2cce5bf81..b9902f3c312 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -1109,6 +1109,11 @@ export class Task extends Session { return result; } + async mergeConsensusJobs(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.mergeConsensusJobs); + return result; + } + async backup(targetStorage: Storage, useDefaultSettings: boolean, fileName?: string) { const result = await PluginRegistry.apiWrapper.call( this, From 4a11184686577bc3bcea046eb7f53fe5811ca251 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 18 Jun 2024 06:34:41 +0530 Subject: [PATCH 034/301] added merging task actions in the frontend --- cvat-ui/src/actions/tasks-actions.ts | 51 +++++++++++++++++++ .../components/actions-menu/actions-menu.tsx | 32 ++++++++++++ .../containers/actions-menu/actions-menu.tsx | 13 +++++ cvat-ui/src/reducers/index.ts | 5 ++ cvat-ui/src/reducers/notifications-reducer.ts | 18 +++++++ cvat-ui/src/reducers/tasks-reducer.ts | 49 ++++++++++++++++++ 6 files changed, 168 insertions(+) diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index fcce3d5de64..f916250ab15 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -27,6 +27,9 @@ export enum TasksActionTypes { GET_TASK_PREVIEW_SUCCESS = 'GET_TASK_PREVIEW_SUCCESS', GET_TASK_PREVIEW_FAILED = 'GET_TASK_PREVIEW_FAILED', UPDATE_TASK_IN_STATE = 'UPDATE_TASK_IN_STATE', + MERGE_TASK_CONSENSUS = 'AGREEGATE_TASK_CONSENSUS', + MERGE_TASK_CONSENSUS_SUCCESS = 'AGREEGATE_TASK_CONSENSUS_SUCCESS', + MERGE_TASK_CONSENSUS_FAILED = 'AGREEGATE_TASK_CONSENSUS_FAILED', } function getTasks(query: Partial, updateQuery: boolean): AnyAction { @@ -120,6 +123,40 @@ function deleteTaskFailed(taskID: number, error: any): AnyAction { return action; } +function mergeTaskConsensusJobs(taskID: number): AnyAction { + const action = { + type: TasksActionTypes.MERGE_TASK_CONSENSUS, + payload: { + taskID, + }, + }; + + return action; +} + +function mergeTaskConsensusJobsSuccess(taskID: number): AnyAction { + const action = { + type: TasksActionTypes.MERGE_TASK_CONSENSUS_SUCCESS, + payload: { + taskID, + }, + }; + + return action; +} + +function mergeTaskConsensusJobsFailed(taskID: number, error: any): AnyAction { + const action = { + type: TasksActionTypes.MERGE_TASK_CONSENSUS_FAILED, + payload: { + taskID, + error, + }, + }; + + return action; +} + export function deleteTaskAsync(taskInstance: any): ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { try { @@ -134,6 +171,20 @@ export function deleteTaskAsync(taskInstance: any): ThunkAction, { }; } +export function mergeTaskConsensusJobsAsync(taskInstance: any): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + try { + dispatch(mergeTaskConsensusJobs(taskInstance.id)); + await taskInstance.mergeConsensusJobs(); + } catch (error) { + dispatch(mergeTaskConsensusJobsFailed(taskInstance.id, error)); + return; + } + + dispatch(mergeTaskConsensusJobsSuccess(taskInstance.id)); + }; +} + function createTaskFailed(error: any): AnyAction { const action = { type: TasksActionTypes.CREATE_TASK_FAILED, diff --git a/cvat-ui/src/components/actions-menu/actions-menu.tsx b/cvat-ui/src/components/actions-menu/actions-menu.tsx index 6f17e335c8a..ad03c278bfa 100644 --- a/cvat-ui/src/components/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/components/actions-menu/actions-menu.tsx @@ -24,6 +24,8 @@ interface Props { inferenceIsActive: boolean; taskDimension: DimensionType; backupIsActive: boolean; + mergingIsActive: boolean; + consensusJobPerSegment: number; onClickMenu: (params: MenuInfo) => void; } @@ -36,6 +38,7 @@ export enum Actions { OPEN_BUG_TRACKER = 'open_bug_tracker', BACKUP_TASK = 'backup_task', VIEW_ANALYTICS = 'view_analytics', + MERGE_TASK_CONSENSUS_JOBS = 'merge_task_consensus_jobs', } function ActionsMenuComponent(props: Props): JSX.Element { @@ -45,6 +48,8 @@ function ActionsMenuComponent(props: Props): JSX.Element { bugTracker, inferenceIsActive, backupIsActive, + mergingIsActive, + consensusJobPerSegment, onClickMenu, } = props; @@ -70,6 +75,19 @@ function ActionsMenuComponent(props: Props): JSX.Element { }, okText: 'Delete', }); + } else if (params.key === Actions.MERGE_TASK_CONSENSUS_JOBS) { + Modal.confirm({ + title: `The consensus jobs in task ${taskID} will be merged`, + content: 'Exisitng annotations in normal jobs will be lost. Continue?', + className: 'cvat-modal-confirm-delete-task', + onOk: () => { + onClickMenu(params); + }, + okButtonProps: { + type: 'primary', + }, + okText: 'Merge', + }); } else { onClickMenu(params); } @@ -116,6 +134,20 @@ function ActionsMenuComponent(props: Props): JSX.Element { ), 50]); + if (consensusJobPerSegment) { + menuItems.push([( + + } + > + Merge consensus jobs + + + ), 55]); + } + if (projectID === null) { menuItems.push([( Move to project diff --git a/cvat-ui/src/containers/actions-menu/actions-menu.tsx b/cvat-ui/src/containers/actions-menu/actions-menu.tsx index 07bd9c6e551..f24cc1cc4ae 100644 --- a/cvat-ui/src/containers/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/containers/actions-menu/actions-menu.tsx @@ -13,6 +13,7 @@ import { CombinedState } from 'reducers'; import { modelsActions } from 'actions/models-actions'; import { deleteTaskAsync, + mergeTaskConsensusJobsAsync, switchMoveTaskModalVisible, } from 'actions/tasks-actions'; import { exportActions } from 'actions/export-actions'; @@ -27,6 +28,7 @@ interface StateToProps { annotationFormats: any; inferenceIsActive: boolean; backupIsActive: boolean; + mergingIsActive: boolean; } interface DispatchToProps { @@ -35,6 +37,7 @@ interface DispatchToProps { openRunModelWindow: (taskInstance: any) => void; deleteTask: (taskInstance: any) => void; openMoveTaskToProjectWindow: (taskInstance: any) => void; + onMergeTaskConsensusJobs: (taskInstance: any) => void; } function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { @@ -50,6 +53,7 @@ function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { annotationFormats, inferenceIsActive: tid in state.models.inferences, backupIsActive: state.export.tasks.backup.current[tid], + mergingIsActive: state.tasks.activities.mergingConsensus[tid], }; } @@ -74,6 +78,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { openMoveTaskToProjectWindow: (taskId: number): void => { dispatch(switchMoveTaskModalVisible(true, taskId)); }, + onMergeTaskConsensusJobs: (taskInstance: any): void => { + dispatch(mergeTaskConsensusJobsAsync(taskInstance)); + }, }; } @@ -83,12 +90,14 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): annotationFormats: { loaders, dumpers }, inferenceIsActive, backupIsActive, + mergingIsActive, showExportModal, showImportModal, deleteTask, openRunModelWindow, openMoveTaskToProjectWindow, onViewAnalytics, + onMergeTaskConsensusJobs, } = props; const onClickMenu = (params: MenuInfo): void | JSX.Element => { const [action] = params.keyPath; @@ -108,6 +117,8 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): showImportModal(taskInstance); } else if (action === Actions.VIEW_ANALYTICS) { onViewAnalytics(); + } else if (action === Actions.MERGE_TASK_CONSENSUS_JOBS) { + onMergeTaskConsensusJobs(taskInstance); } }; @@ -123,6 +134,8 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): onClickMenu={onClickMenu} taskDimension={taskInstance.dimension} backupIsActive={backupIsActive} + mergingIsActive={mergingIsActive} + consensusJobPerSegment={taskInstance.consensusJobPerSegment} /> ); } diff --git a/cvat-ui/src/reducers/index.ts b/cvat-ui/src/reducers/index.ts index 27a35d15bff..f5c56f0c9d5 100644 --- a/cvat-ui/src/reducers/index.ts +++ b/cvat-ui/src/reducers/index.ts @@ -109,6 +109,9 @@ export interface TasksState { deletes: { [tid: number]: boolean; // deleted (deleting if in dictionary) }; + mergingConsensus: { + [tid: number]: boolean; + }; }; } @@ -468,6 +471,7 @@ export interface NotificationsState { exporting: null | ErrorState; importing: null | ErrorState; moving: null | ErrorState; + mergingConsensus: null | ErrorState; }; jobs: { updating: null | ErrorState; @@ -585,6 +589,7 @@ export interface NotificationsState { loadingDone: string; importingDone: string; movingDone: string; + mergingConsensusDone: string; }; models: { inferenceDone: string; diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index 5d6d7c6d0ff..264a64df548 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -679,6 +679,24 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case TasksActionTypes.MERGE_TASK_CONSENSUS_FAILED: { + const { taskID } = action.payload; + return { + ...state, + errors: { + ...state.errors, + tasks: { + ...state.errors.tasks, + mergingConsensus: { + message: `Could not merge the [task ${taskID}](/tasks/${taskID})`, + reason: action.payload.error, + shouldLog: !(action.payload.error instanceof ServerError), + className: 'cvat-notification-notice-merge-task-failed', + }, + }, + }, + }; + } case TasksActionTypes.CREATE_TASK_FAILED: { return { ...state, diff --git a/cvat-ui/src/reducers/tasks-reducer.ts b/cvat-ui/src/reducers/tasks-reducer.ts index ce2e88258c0..827fd87511f 100644 --- a/cvat-ui/src/reducers/tasks-reducer.ts +++ b/cvat-ui/src/reducers/tasks-reducer.ts @@ -31,6 +31,7 @@ const defaultState: TasksState = { }, activities: { deletes: {}, + mergingConsensus: {}, }, }; @@ -133,6 +134,54 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState }, }; } + case TasksActionTypes.MERGE_TASK_CONSENSUS: { + const { taskID } = action.payload; + const { mergingConsensus } = state.activities; + + mergingConsensus[taskID] = true; + + return { + ...state, + activities: { + ...state.activities, + mergingConsensus: { + ...mergingConsensus, + }, + }, + }; + } + case TasksActionTypes.MERGE_TASK_CONSENSUS_SUCCESS: { + const { taskID } = action.payload; + const { mergingConsensus } = state.activities; + + mergingConsensus[taskID] = false; + + return { + ...state, + activities: { + ...state.activities, + mergingConsensus: { + ...mergingConsensus, + }, + }, + }; + } + case TasksActionTypes.MERGE_TASK_CONSENSUS_FAILED: { + const { taskID } = action.payload; + const { mergingConsensus } = state.activities; + + delete mergingConsensus[taskID]; + + return { + ...state, + activities: { + ...state.activities, + mergingConsensus: { + ...mergingConsensus, + }, + }, + }; + } case TasksActionTypes.SWITCH_MOVE_TASK_MODAL_VISIBLE: { return { ...state, From d44ffdc5f8f9bf8146574b80bb82509ccf42c413 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 19 Jun 2024 04:58:50 +0530 Subject: [PATCH 035/301] made agreegate request method to PUT instead of GET --- cvat-core/src/server-proxy.ts | 2 +- cvat/apps/engine/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index 4f0178b58b6..583cc88fb76 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -803,7 +803,7 @@ async function mergeConsensusJobs(id: number): Promise { return new Promise((resolve, reject) => { async function request() { try { - const response = await Axios.get(url); + const response = await Axios.put(url); const { status } = response; if (status === 202) { setTimeout(request, 3000); diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 17b5f8bd66a..46b59d99f6a 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -910,7 +910,7 @@ def export_backup(self, request, pk=None): '400': OpenApiResponse(description='Agreegating a task without data is not allowed'), }, ) - @action(methods=['GET'], detail=True, url_path=r'agreegate/?$') + @action(methods=['PUT'], detail=True, url_path=r'agreegate/?$') def aggregate(self, request, pk=None): task = self.get_object() From a65d8199f531dd8ac0a09dd89a6e169ac4e41be3 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 19 Jun 2024 05:00:34 +0530 Subject: [PATCH 036/301] added notification message when agreegation is succeded or failed --- cvat-ui/src/reducers/notifications-reducer.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index 264a64df548..4c4af1ee177 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -61,6 +61,7 @@ const defaultState: NotificationsState = { exporting: null, importing: null, moving: null, + mergingConsensus: null, }, jobs: { updating: null, @@ -178,6 +179,7 @@ const defaultState: NotificationsState = { loadingDone: '', importingDone: '', movingDone: '', + mergingConsensusDone: '', }, models: { inferenceDone: '', @@ -681,6 +683,9 @@ export default function (state = defaultState, action: AnyAction): Notifications } case TasksActionTypes.MERGE_TASK_CONSENSUS_FAILED: { const { taskID } = action.payload; + if (action.payload.error.code === 400) { + action.payload.error.message = "Consensus Jobs aren't annotated."; + } return { ...state, errors: { @@ -697,6 +702,20 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case TasksActionTypes.MERGE_TASK_CONSENSUS_SUCCESS: { + const { taskID } = action.payload; + return { + ...state, + messages: { + ...state.messages, + tasks: { + ...state.messages.tasks, + mergingConsensusDone: `Consensus Jobs in the [task ${taskID}](/tasks/${taskID})\ + have been merged`, + }, + }, + }; + } case TasksActionTypes.CREATE_TASK_FAILED: { return { ...state, From ee5c863e415463fff836979f86e683bc461a6fa1 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 19 Jun 2024 05:15:55 +0530 Subject: [PATCH 037/301] removed consensus parameters from TaskData, missed to remove while moving them from TaskData to Task --- cvat/apps/dataset_manager/bindings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 9eb7fd0f7db..5537f96b122 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -780,8 +780,6 @@ def meta_for_task(db_task, host, label_mapping=None): ("start_frame", str(db_task.data.start_frame)), ("stop_frame", str(db_task.data.stop_frame)), ("frame_filter", db_task.data.frame_filter), - ("consensus_job_per_segment", str(db_task.consensus_job_per_segment)), - ("agreement_score_threshold", str(db_task.agreement_score_threshold)), ("segments", [ ("segment", OrderedDict([ From ea3f4aade428e86d6dbf61373bd818c7845ddb6f Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 19 Jun 2024 05:20:07 +0530 Subject: [PATCH 038/301] removed consensus parameters from cvat-sdk TaskData, missed to remove while moving them from TaskData to Task --- cvat-sdk/cvat_sdk/core/proxies/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat-sdk/cvat_sdk/core/proxies/tasks.py b/cvat-sdk/cvat_sdk/core/proxies/tasks.py index 5d61749969a..2ed2f1d46ea 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/tasks.py +++ b/cvat-sdk/cvat_sdk/core/proxies/tasks.py @@ -78,7 +78,7 @@ def upload_data( """ params = params or {} - data = {"image_quality": 70, "consensus_job_per_segment": 0, "agreement_score_threshold": 0} + data = {"image_quality": 70} data.update( filter_dict( From 296d01714c4f1ad521dc35c8936320dd1a4b6269 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 19 Jun 2024 05:46:11 +0530 Subject: [PATCH 039/301] squashed migrations files corresponding to adding parent_job_id, consensus_job_per_segment and agreement_score_threshold into one migration file --- ...ask_agreement_score_threshold_and_more.py} | 15 +++++------- ...e_job_id_task_consensus_job_per_segment.py | 23 ------------------- .../0080_alter_job_source_job_id.py | 18 --------------- .../0081_alter_job_source_job_id.py | 18 --------------- ...82_alter_task_consensus_job_per_segment.py | 18 --------------- ...83_alter_task_consensus_job_per_segment.py | 18 --------------- ...task_consensus_job_per_segment_and_more.py | 22 ------------------ ...85_alter_data_consensus_job_per_segment.py | 18 --------------- ...86_alter_data_consensus_job_per_segment.py | 18 --------------- ...data_agreement_score_threshold_and_more.py | 23 ------------------- ...ource_job_id_job_parent_job_id_and_more.py | 23 ------------------- 11 files changed, 6 insertions(+), 208 deletions(-) rename cvat/apps/engine/migrations/{0089_remove_data_agreement_score_threshold_and_more.py => 0079_job_parent_job_id_task_agreement_score_threshold_and_more.py} (58%) delete mode 100644 cvat/apps/engine/migrations/0079_job_source_job_id_task_consensus_job_per_segment.py delete mode 100644 cvat/apps/engine/migrations/0080_alter_job_source_job_id.py delete mode 100644 cvat/apps/engine/migrations/0081_alter_job_source_job_id.py delete mode 100644 cvat/apps/engine/migrations/0082_alter_task_consensus_job_per_segment.py delete mode 100644 cvat/apps/engine/migrations/0083_alter_task_consensus_job_per_segment.py delete mode 100644 cvat/apps/engine/migrations/0084_remove_task_consensus_job_per_segment_and_more.py delete mode 100644 cvat/apps/engine/migrations/0085_alter_data_consensus_job_per_segment.py delete mode 100644 cvat/apps/engine/migrations/0086_alter_data_consensus_job_per_segment.py delete mode 100644 cvat/apps/engine/migrations/0087_data_agreement_score_threshold_and_more.py delete mode 100644 cvat/apps/engine/migrations/0088_rename_source_job_id_job_parent_job_id_and_more.py diff --git a/cvat/apps/engine/migrations/0089_remove_data_agreement_score_threshold_and_more.py b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_agreement_score_threshold_and_more.py similarity index 58% rename from cvat/apps/engine/migrations/0089_remove_data_agreement_score_threshold_and_more.py rename to cvat/apps/engine/migrations/0079_job_parent_job_id_task_agreement_score_threshold_and_more.py index 942bfb41cff..0c67ee5a237 100644 --- a/cvat/apps/engine/migrations/0089_remove_data_agreement_score_threshold_and_more.py +++ b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_agreement_score_threshold_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-06-06 13:59 +# Generated by Django 4.2.11 on 2024-06-18 23:57 from django.db import migrations, models @@ -6,17 +6,14 @@ class Migration(migrations.Migration): dependencies = [ - ("engine", "0088_rename_source_job_id_job_parent_job_id_and_more"), + ("engine", "0078_alter_cloudstorage_credentials"), ] operations = [ - migrations.RemoveField( - model_name="data", - name="agreement_score_threshold", - ), - migrations.RemoveField( - model_name="data", - name="consensus_job_per_segment", + migrations.AddField( + model_name="job", + name="parent_job_id", + field=models.PositiveIntegerField(blank=True, default=None, null=True), ), migrations.AddField( model_name="task", diff --git a/cvat/apps/engine/migrations/0079_job_source_job_id_task_consensus_job_per_segment.py b/cvat/apps/engine/migrations/0079_job_source_job_id_task_consensus_job_per_segment.py deleted file mode 100644 index 2ef469179ac..00000000000 --- a/cvat/apps/engine/migrations/0079_job_source_job_id_task_consensus_job_per_segment.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.11 on 2024-05-30 12:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0078_alter_cloudstorage_credentials"), - ] - - operations = [ - migrations.AddField( - model_name="job", - name="source_job_id", - field=models.PositiveIntegerField(null=True), - ), - migrations.AddField( - model_name="task", - name="consensus_job_per_segment", - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/cvat/apps/engine/migrations/0080_alter_job_source_job_id.py b/cvat/apps/engine/migrations/0080_alter_job_source_job_id.py deleted file mode 100644 index 42bb6f219b7..00000000000 --- a/cvat/apps/engine/migrations/0080_alter_job_source_job_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.11 on 2024-05-30 13:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0079_job_source_job_id_task_consensus_job_per_segment"), - ] - - operations = [ - migrations.AlterField( - model_name="job", - name="source_job_id", - field=models.PositiveIntegerField(default=None, null=True), - ), - ] diff --git a/cvat/apps/engine/migrations/0081_alter_job_source_job_id.py b/cvat/apps/engine/migrations/0081_alter_job_source_job_id.py deleted file mode 100644 index 44947500a68..00000000000 --- a/cvat/apps/engine/migrations/0081_alter_job_source_job_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.11 on 2024-05-30 13:13 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0080_alter_job_source_job_id"), - ] - - operations = [ - migrations.AlterField( - model_name="job", - name="source_job_id", - field=models.PositiveIntegerField(blank=True, default=None, null=True), - ), - ] diff --git a/cvat/apps/engine/migrations/0082_alter_task_consensus_job_per_segment.py b/cvat/apps/engine/migrations/0082_alter_task_consensus_job_per_segment.py deleted file mode 100644 index cd515280e18..00000000000 --- a/cvat/apps/engine/migrations/0082_alter_task_consensus_job_per_segment.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.11 on 2024-05-30 13:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0081_alter_job_source_job_id"), - ] - - operations = [ - migrations.AlterField( - model_name="task", - name="consensus_job_per_segment", - field=models.PositiveIntegerField(default=1), - ), - ] diff --git a/cvat/apps/engine/migrations/0083_alter_task_consensus_job_per_segment.py b/cvat/apps/engine/migrations/0083_alter_task_consensus_job_per_segment.py deleted file mode 100644 index 91a3440f7e3..00000000000 --- a/cvat/apps/engine/migrations/0083_alter_task_consensus_job_per_segment.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.11 on 2024-05-31 01:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0082_alter_task_consensus_job_per_segment"), - ] - - operations = [ - migrations.AlterField( - model_name="task", - name="consensus_job_per_segment", - field=models.PositiveIntegerField(blank=True, default=1), - ), - ] diff --git a/cvat/apps/engine/migrations/0084_remove_task_consensus_job_per_segment_and_more.py b/cvat/apps/engine/migrations/0084_remove_task_consensus_job_per_segment_and_more.py deleted file mode 100644 index 1a5248eb655..00000000000 --- a/cvat/apps/engine/migrations/0084_remove_task_consensus_job_per_segment_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.11 on 2024-05-31 02:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0083_alter_task_consensus_job_per_segment"), - ] - - operations = [ - migrations.RemoveField( - model_name="task", - name="consensus_job_per_segment", - ), - migrations.AddField( - model_name="data", - name="consensus_job_per_segment", - field=models.PositiveIntegerField(blank=True, default=1), - ), - ] diff --git a/cvat/apps/engine/migrations/0085_alter_data_consensus_job_per_segment.py b/cvat/apps/engine/migrations/0085_alter_data_consensus_job_per_segment.py deleted file mode 100644 index 9da6d7f1fe3..00000000000 --- a/cvat/apps/engine/migrations/0085_alter_data_consensus_job_per_segment.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.11 on 2024-05-31 02:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0084_remove_task_consensus_job_per_segment_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="data", - name="consensus_job_per_segment", - field=models.IntegerField(blank=True, default=1), - ), - ] diff --git a/cvat/apps/engine/migrations/0086_alter_data_consensus_job_per_segment.py b/cvat/apps/engine/migrations/0086_alter_data_consensus_job_per_segment.py deleted file mode 100644 index b771af615f5..00000000000 --- a/cvat/apps/engine/migrations/0086_alter_data_consensus_job_per_segment.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.11 on 2024-05-31 10:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0085_alter_data_consensus_job_per_segment"), - ] - - operations = [ - migrations.AlterField( - model_name="data", - name="consensus_job_per_segment", - field=models.IntegerField(blank=True, default=0), - ), - ] diff --git a/cvat/apps/engine/migrations/0087_data_agreement_score_threshold_and_more.py b/cvat/apps/engine/migrations/0087_data_agreement_score_threshold_and_more.py deleted file mode 100644 index e2a5f9057f7..00000000000 --- a/cvat/apps/engine/migrations/0087_data_agreement_score_threshold_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.11 on 2024-06-04 12:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0086_alter_data_consensus_job_per_segment"), - ] - - operations = [ - migrations.AddField( - model_name="data", - name="agreement_score_threshold", - field=models.FloatField(blank=True, default=0), - ), - migrations.AlterField( - model_name="data", - name="consensus_job_per_segment", - field=models.PositiveSmallIntegerField(blank=True, default=0), - ), - ] diff --git a/cvat/apps/engine/migrations/0088_rename_source_job_id_job_parent_job_id_and_more.py b/cvat/apps/engine/migrations/0088_rename_source_job_id_job_parent_job_id_and_more.py deleted file mode 100644 index 2d5ecc4d3d9..00000000000 --- a/cvat/apps/engine/migrations/0088_rename_source_job_id_job_parent_job_id_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.11 on 2024-06-04 22:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0087_data_agreement_score_threshold_and_more"), - ] - - operations = [ - migrations.RenameField( - model_name="job", - old_name="source_job_id", - new_name="parent_job_id", - ), - migrations.AlterField( - model_name="data", - name="consensus_job_per_segment", - field=models.IntegerField(blank=True, default=0), - ), - ] From 57148d79afcb0bbb9a7cdfd40c5efc75ad681cd2 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 19 Jun 2024 05:47:19 +0530 Subject: [PATCH 040/301] Fixed the validation method for consensus params, which failed while editing the agreement_score_threshold --- cvat/apps/engine/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 0c99097cbe1..0a460727ddc 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1315,8 +1315,8 @@ def validate(self, attrs): if sublabels != target_project_sublabel_names.get(label): raise serializers.ValidationError('All task or project label names must be mapped to the target project') - consensus_job_per_segment = attrs.get('consensus_job_per_segment', None) - agreement_score_threshold = attrs.get('agreement_score_threshold', None) + consensus_job_per_segment = attrs.get('consensus_job_per_segment', self.instance.consensus_job_per_segment if self.instance else None) + agreement_score_threshold = attrs.get('agreement_score_threshold', self.instance.agreement_score_threshold if self.instance else None) if consensus_job_per_segment is None: raise serializers.ValidationError("Consensus job per segment can't be None") From 7f95c30a869ffdbd9fcd58850896a8d94290775b Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 19 Jun 2024 06:05:15 +0530 Subject: [PATCH 041/301] while handling Task Annoatations as a whole only operate on normal jobs --- cvat/apps/dataset_manager/task.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 9b983586bb5..ba792dcfc8c 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -731,7 +731,7 @@ def __init__(self, pk): # Postgres doesn't guarantee an order by default without explicit order_by # Only select normal jobs, not ground truth or consensus jobs self.db_jobs = models.Job.objects.select_related("segment").filter( - segment__task_id=pk, type=models.JobType.ANNOTATION.value, + segment__task_id=pk, type=models.JobType.ANNOTATION.value, parent_job_id=None, ).order_by('id') self.ir_data = AnnotationIR(self.db_task.dimension) @@ -743,8 +743,6 @@ def _patch_data(self, data, action): splitted_data = {} jobs = {} for db_job in self.db_jobs: - if db_job.parent_job_id is not None: - continue jid = db_job.id start = db_job.segment.start_frame stop = db_job.segment.stop_frame @@ -785,7 +783,7 @@ def init_from_db(self): self.reset() for db_job in self.db_jobs: - if db_job.type != models.JobType.ANNOTATION or db_job.parent_job_id is not None: + if db_job.type != models.JobType.ANNOTATION: continue annotation = JobAnnotation(db_job.id, is_prefetched=True) From ee7c46625dd939a14d8483870cee7475a18bb396 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 19 Jun 2024 10:54:58 +0530 Subject: [PATCH 042/301] fixed spelling of aggregate --- cvat-core/src/server-proxy.ts | 2 +- cvat-ui/src/actions/tasks-actions.ts | 6 +++--- cvat/apps/consensus/views.py | 4 ++-- cvat/apps/engine/views.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index 583cc88fb76..8d2d2c0b01c 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -798,7 +798,7 @@ async function deleteTask(id: number, organizationID: string | null = null): Pro async function mergeConsensusJobs(id: number): Promise { const { backendAPI } = config; - const url = `${backendAPI}/tasks/${id}/agreegate`; + const url = `${backendAPI}/tasks/${id}/aggregate`; return new Promise((resolve, reject) => { async function request() { diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index f916250ab15..82760080ac0 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -27,9 +27,9 @@ export enum TasksActionTypes { GET_TASK_PREVIEW_SUCCESS = 'GET_TASK_PREVIEW_SUCCESS', GET_TASK_PREVIEW_FAILED = 'GET_TASK_PREVIEW_FAILED', UPDATE_TASK_IN_STATE = 'UPDATE_TASK_IN_STATE', - MERGE_TASK_CONSENSUS = 'AGREEGATE_TASK_CONSENSUS', - MERGE_TASK_CONSENSUS_SUCCESS = 'AGREEGATE_TASK_CONSENSUS_SUCCESS', - MERGE_TASK_CONSENSUS_FAILED = 'AGREEGATE_TASK_CONSENSUS_FAILED', + MERGE_TASK_CONSENSUS = 'AGGREGATE_TASK_CONSENSUS', + MERGE_TASK_CONSENSUS_SUCCESS = 'AGGREGATE_TASK_CONSENSUS_SUCCESS', + MERGE_TASK_CONSENSUS_FAILED = 'AGGREGATE_TASK_CONSENSUS_FAILED', } function getTasks(query: Partial, updateQuery: boolean): AnyAction { diff --git a/cvat/apps/consensus/views.py b/cvat/apps/consensus/views.py index 4f732f801e2..39877857a60 100644 --- a/cvat/apps/consensus/views.py +++ b/cvat/apps/consensus/views.py @@ -13,6 +13,6 @@ or somewhat like storing report attributes. -/agreegate/ => list of merge reports +/aggregate/ => list of merge reports -""" \ No newline at end of file +""" diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 46b59d99f6a..b4fa6d087c7 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -905,12 +905,12 @@ def export_backup(self, request, pk=None): @extend_schema(summary="Aggregate data of a task", responses={ - '201': OpenApiResponse(description='Consensus Jobs Agreegated'), + '201': OpenApiResponse(description='Consensus Jobs Aggregated'), '202': OpenApiResponse(description='Agreegation of Consensus Jobs started'), '400': OpenApiResponse(description='Agreegating a task without data is not allowed'), }, ) - @action(methods=['PUT'], detail=True, url_path=r'agreegate/?$') + @action(methods=['PUT'], detail=True, url_path=r'aggregate/?$') def aggregate(self, request, pk=None): task = self.get_object() From e5fcf92ef6d8c33c98a00a5d770791779c3d8b8f Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 19 Jun 2024 11:00:36 +0530 Subject: [PATCH 043/301] added transaction.atomic decorator to _merge_consensus_jobs function as it's modifying database --- cvat/apps/consensus/merge_consensus_jobs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index ae5f152f636..746ae915835 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -12,6 +12,7 @@ from cvat.apps.engine.utils import get_rq_lock_by_user, get_rq_job_meta, define_dependent_job, process_failed_job from cvat.apps.engine.serializers import RqIdSerializer from django.utils import timezone +from django.db import transaction def get_consensus_jobs(task_id: int): jobs = {} # parent_job_id -> [consensus_job_id] @@ -25,6 +26,8 @@ def get_consensus_jobs(task_id: int): def get_annotations(job_id: int): return JobDataProvider(job_id).dm_dataset + +@transaction.atomic def _merge_consensus_jobs(task_id: int): jobs = get_consensus_jobs(task_id) merger = IntersectMerge() @@ -50,7 +53,7 @@ def _merge_consensus_jobs(task_id: int): parent_job = JobDataProvider(parent_job_id) - # imports the annotations in the this `parent_job.job_data` instance + # imports the annotations in the `parent_job.job_data` instance import_dm_annotations(merged_dataset, parent_job.job_data) # updates the annotations in the job From 83a2866e90c0394322b2ab35f4798b8fe0e2dc30 Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 20 Jun 2024 16:54:44 +0530 Subject: [PATCH 044/301] added datumaro's default IntersectMerge implementation --- cvat/apps/consensus/new_intersect_merge.py | 1959 ++++++++++++++++++++ 1 file changed, 1959 insertions(+) create mode 100644 cvat/apps/consensus/new_intersect_merge.py diff --git a/cvat/apps/consensus/new_intersect_merge.py b/cvat/apps/consensus/new_intersect_merge.py new file mode 100644 index 00000000000..e6e46dc3faf --- /dev/null +++ b/cvat/apps/consensus/new_intersect_merge.py @@ -0,0 +1,1959 @@ +# Copyright (C) 2020-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import hashlib +import logging as log +from collections import OrderedDict +from copy import deepcopy +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Type, Union +from unittest import TestCase + +import attr +import cv2 +import numpy as np +from attr import attrib, attrs + +from datumaro.components.annotation import ( + Annotation, + AnnotationType, + Bbox, + Label, + LabelCategories, + MaskCategories, + PointsCategories, +) +from datumaro.components.cli_plugin import CliPlugin +from datumaro.components.dataset import Dataset, DatasetItemStorage, IDataset +from datumaro.components.errors import ( + AnnotationsTooCloseError, + ConflictingCategoriesError, + DatasetMergeError, + FailedAttrVotingError, + FailedLabelVotingError, + MediaTypeError, + MismatchingAttributesError, + MismatchingImageInfoError, + MismatchingMediaError, + MismatchingMediaPathError, + NoMatchingAnnError, + NoMatchingItemError, + VideoMergeError, + WrongGroupError, +) +from datumaro.components.extractor import CategoriesInfo, DatasetItem +from datumaro.components.media import Image, MediaElement, MultiframeImage, PointCloud, Video +from datumaro.util import filter_dict, find +from datumaro.util.annotation_util import ( + OKS, + approximate_line, + bbox_iou, + find_instances, + max_bbox, + mean_bbox, + segment_iou, +) +from datumaro.util.attrs_util import default_if_none, ensure_cls + + +def match_annotations_equal(a, b): + matches = [] + a_unmatched = a[:] + b_unmatched = b[:] + for a_ann in a: + for b_ann in b_unmatched: + if a_ann != b_ann: + continue + + matches.append((a_ann, b_ann)) + a_unmatched.remove(a_ann) + b_unmatched.remove(b_ann) + break + + return matches, a_unmatched, b_unmatched + + +def merge_annotations_equal(a, b): + matches, a_unmatched, b_unmatched = match_annotations_equal(a, b) + return [ann_a for (ann_a, _) in matches] + a_unmatched + b_unmatched + + +def merge_categories(sources): + categories = {} + for source_idx, source in enumerate(sources): + for cat_type, source_cat in source.items(): + existing_cat = categories.setdefault(cat_type, source_cat) + if existing_cat != source_cat and len(source_cat) != 0: + if len(existing_cat) == 0: + categories[cat_type] = source_cat + else: + raise ConflictingCategoriesError( + "Merging of datasets with different categories is " + "only allowed in 'merge' command.", + sources=list(range(source_idx)), + ) + return categories + + +class MergingStrategy(CliPlugin): + @classmethod + def merge(cls, sources, **options): + instance = cls(**options) + return instance(sources) + + def __init__(self, **options): + super().__init__(**options) + self.__dict__["_sources"] = None + + def __call__(self, sources): + raise NotImplementedError() + + +class ExactMerge: + """ + Merges several datasets using the "simple" algorithm: + - items are matched by (id, subset) pairs + - matching items share the media info available: + + - nothing + nothing = nothing + - nothing + something = something + - something A + something B = conflict + - annotations are matched by value and shared + - in case of conflicts, throws an error + """ + + @classmethod + def merge(cls, *sources: IDataset) -> DatasetItemStorage: + items = DatasetItemStorage() + for source_idx, source in enumerate(sources): + for item in source: + existing_item = items.get(item.id, item.subset) + if existing_item is not None: + try: + item = cls._merge_items(existing_item, item) + except DatasetMergeError as e: + e.sources = set(range(source_idx)) + raise e + + items.put(item) + return items + + @classmethod + def _merge_items(cls, existing_item: DatasetItem, current_item: DatasetItem) -> DatasetItem: + return existing_item.wrap( + media=cls._merge_media(existing_item, current_item), + attributes=cls._merge_attrs( + existing_item.attributes, + current_item.attributes, + item_id=(existing_item.id, existing_item.subset), + ), + annotations=cls._merge_anno(existing_item.annotations, current_item.annotations), + ) + + @staticmethod + def _merge_attrs(a: Dict[str, Any], b: Dict[str, Any], item_id: Tuple[str, str]) -> Dict: + merged = {} + + for name in a.keys() | b.keys(): + a_val = a.get(name, None) + b_val = b.get(name, None) + + if name not in a: + m_val = b_val + elif name not in b: + m_val = a_val + elif a_val != b_val: + raise MismatchingAttributesError(item_id, name, a_val, b_val) + else: + m_val = a_val + + merged[name] = m_val + + return merged + + @classmethod + def _merge_media( + cls, item_a: DatasetItem, item_b: DatasetItem + ) -> Union[Image, PointCloud, Video]: + if (not item_a.media or isinstance(item_a.media, Image)) and ( + not item_b.media or isinstance(item_b.media, Image) + ): + media = cls._merge_images(item_a, item_b) + elif (not item_a.media or isinstance(item_a.media, PointCloud)) and ( + not item_b.media or isinstance(item_b.media, PointCloud) + ): + media = cls._merge_point_clouds(item_a, item_b) + elif (not item_a.media or isinstance(item_a.media, Video)) and ( + not item_b.media or isinstance(item_b.media, Video) + ): + media = cls._merge_videos(item_a, item_b) + elif (not item_a.media or isinstance(item_a.media, MultiframeImage)) and ( + not item_b.media or isinstance(item_b.media, MultiframeImage) + ): + media = cls._merge_multiframe_images(item_a, item_b) + elif (not item_a.media or isinstance(item_a.media, MediaElement)) and ( + not item_b.media or isinstance(item_b.media, MediaElement) + ): + if isinstance(item_a.media, MediaElement) and isinstance(item_b.media, MediaElement): + if ( + item_a.media.path + and item_b.media.path + and item_a.media.path != item_b.media.path + ): + raise MismatchingMediaPathError( + (item_a.id, item_a.subset), item_a.media.path, item_b.media.path + ) + + if item_a.media.path: + media = item_a.media + else: + media = item_b.media + + elif isinstance(item_a.media, MediaElement): + media = item_a.media + else: + media = item_b.media + else: + raise MismatchingMediaError((item_a.id, item_a.subset), item_a.media, item_b.media) + return media + + @staticmethod + def _merge_images(item_a: DatasetItem, item_b: DatasetItem) -> Image: + media = None + + if isinstance(item_a.media, Image) and isinstance(item_b.media, Image): + if ( + item_a.media.path + and item_b.media.path + and item_a.media.path != item_b.media.path + and item_a.media.has_data is item_b.media.has_data + ): + # We use has_data as a replacement for path existence check + # - If only one image has data, we'll use it. The other + # one is just a path metainfo, which is not significant + # in this case. + # - If both images have data or both don't, we need + # to compare paths. + # + # Different paths can aclually point to the same file, + # but it's not the case we'd like to allow here to be + # a "simple" merging strategy used for extractor joining + raise MismatchingMediaPathError( + (item_a.id, item_a.subset), item_a.media.path, item_b.media.path + ) + + if ( + item_a.media.has_size + and item_b.media.has_size + and item_a.media.size != item_b.media.size + ): + raise MismatchingImageInfoError( + (item_a.id, item_a.subset), item_a.media.size, item_b.media.size + ) + + # Avoid direct comparison here for better performance + # If there are 2 "data-only" images, they won't be compared and + # we just use the first one + if item_a.media.has_data: + media = item_a.media + elif item_b.media.has_data: + media = item_b.media + elif item_a.media.path: + media = item_a.media + elif item_b.media.path: + media = item_b.media + elif item_a.media.has_size: + media = item_a.media + elif item_b.media.has_size: + media = item_b.media + else: + assert False, "Unknown image field combination" + + if not media.has_data or not media.has_size: + if item_a.media._size: + media._size = item_a.media._size + elif item_b.media._size: + media._size = item_b.media._size + elif isinstance(item_a.media, Image): + media = item_a.media + else: + media = item_b.media + + return media + + @staticmethod + def _merge_point_clouds(item_a: DatasetItem, item_b: DatasetItem) -> PointCloud: + media = None + + if isinstance(item_a.media, PointCloud) and isinstance(item_b.media, PointCloud): + if item_a.media.path and item_b.media.path and item_a.media.path != item_b.media.path: + raise MismatchingMediaPathError( + (item_a.id, item_a.subset), item_a.media.path, item_b.media.path + ) + + if item_a.media.path or item_a.media.extra_images: + media = item_a.media + + if item_b.media.extra_images: + for image in item_b.media.extra_images: + if image not in media.extra_images: + media.extra_images.append(image) + else: + media = item_b.media + + if item_a.media.extra_images: + for image in item_a.media.extra_images: + if image not in media.extra_images: + media.extra_images.append(image) + + elif isinstance(item_a.media, PointCloud): + media = item_a.media + else: + media = item_b.media + + return media + + @staticmethod + def _merge_videos(item_a: DatasetItem, item_b: DatasetItem) -> Video: + media = None + + if isinstance(item_a.media, Video) and isinstance(item_b.media, Video): + if ( + item_a.media.path is not item_b.media.path + or item_a.media._start_frame is not item_b.media._start_frame + or item_a.media._end_frame is not item_b.media._end_frame + or item_a.media._step is not item_b.media._step + ): + raise VideoMergeError(item_a.id) + + media = item_a.media + elif isinstance(item_a.media, Video): + media = item_a.media + else: + media = item_b.media + + return media + + @staticmethod + def _merge_multiframe_images(item_a: DatasetItem, item_b: DatasetItem) -> MultiframeImage: + media = None + + if isinstance(item_a.media, MultiframeImage) and isinstance(item_b.media, MultiframeImage): + if item_a.media.path and item_b.media.path and item_a.media.path != item_b.media.path: + raise MismatchingMediaPathError( + (item_a.id, item_a.subset), item_a.media.path, item_b.media.path + ) + + if item_a.media.path or item_a.media.data: + media = item_a.media + + if item_b.media.data: + for image in item_b.media.data: + if image not in media.data: + media.data.append(image) + else: + media = item_b.media + + if item_a.media.data: + for image in item_a.media.data: + if image not in media.data: + media.data.append(image) + + elif isinstance(item_a.media, MultiframeImage): + media = item_a.media + else: + media = item_b.media + + return media + + @staticmethod + def _merge_anno(a: Iterable[Annotation], b: Iterable[Annotation]) -> List[Annotation]: + return merge_annotations_equal(a, b) + + @staticmethod + def merge_categories(sources: Iterable[IDataset]) -> CategoriesInfo: + return merge_categories(sources) + + @staticmethod + def merge_media_types(sources: Iterable[IDataset]) -> Type[MediaElement]: + if sources: + media_type = sources[0].media_type() + for s in sources: + if not issubclass(s.media_type(), media_type) or not issubclass( + media_type, s.media_type() + ): + # Symmetric comparision is needed in the case of subclasses: + # eg. Image and ByteImage + raise MediaTypeError("Datasets have different media types") + return media_type + + return None + + +@attrs +class IntersectMerge(MergingStrategy): + @attrs(repr_ns="IntersectMerge", kw_only=True) + class Conf: + pairwise_dist = attrib(converter=float, default=0.5) + sigma = attrib(converter=list, factory=list) + + output_conf_thresh = attrib(converter=float, default=0) + quorum = attrib(converter=int, default=0) + ignored_attributes = attrib(converter=set, factory=set) + + def _groups_converter(value): + result = [] + for group in value: + rg = set() + for label in group: + optional = label.endswith("?") + name = label if not optional else label[:-1] + rg.add((name, optional)) + result.append(rg) + return result + + groups = attrib(converter=_groups_converter, factory=list) + close_distance = attrib(converter=float, default=0.75) + + conf = attrib(converter=ensure_cls(Conf), factory=Conf) + + # Error trackers: + errors = attrib(factory=list, init=False) + + def add_item_error(self, error, *args, **kwargs): + self.errors.append(error(self._item_id, *args, **kwargs)) + + # Indexes: + _dataset_map = attrib(init=False) # id(dataset) -> (dataset, index) + _item_map = attrib(init=False) # id(item) -> (item, id(dataset)) + _ann_map = attrib(init=False) # id(ann) -> (ann, id(item)) + _item_id = attrib(init=False) + _item = attrib(init=False) + + # Misc. + _categories = attrib(init=False) # merged categories + + def __call__(self, datasets): + self._categories = self._merge_categories([d.categories() for d in datasets]) + merged = Dataset( + categories=self._categories, media_type=ExactMerge.merge_media_types(datasets) + ) + + self._check_groups_definition() + + item_matches, item_map = self.match_items(datasets) + self._item_map = item_map + self._dataset_map = {id(d): (d, i) for i, d in enumerate(datasets)} + + for item_id, items in item_matches.items(): + self._item_id = item_id + + if len(items) < len(datasets): + missing_sources = set(id(s) for s in datasets) - set(items) + missing_sources = [self._dataset_map[s][1] for s in missing_sources] + self.add_item_error(NoMatchingItemError, sources=missing_sources) + merged.put(self.merge_items(items)) + + return merged + + def get_ann_source(self, ann_id): + return self._item_map[self._ann_map[ann_id][1]][1] + + def merge_items(self, items): + self._item = next(iter(items.values())) + + self._ann_map = {} + sources = [] + for item in items.values(): + self._ann_map.update({id(a): (a, id(item)) for a in item.annotations}) + sources.append(item.annotations) + log.debug( + "Merging item %s: source annotations %s" % (self._item_id, list(map(len, sources))) + ) + + annotations = self.merge_annotations(sources) + + annotations = [ + a for a in annotations if self.conf.output_conf_thresh <= a.attributes.get("score", 1) + ] + + return self._item.wrap(annotations=annotations) + + def merge_annotations(self, sources): + self._make_mergers(sources) + + clusters = self._match_annotations(sources) + + joined_clusters = sum(clusters.values(), []) + group_map = self._find_cluster_groups(joined_clusters) + + annotations = [] + for t, clusters in clusters.items(): + for cluster in clusters: + self._check_cluster_sources(cluster) + + merged_clusters = self._merge_clusters(t, clusters) + + for merged_ann, cluster in zip(merged_clusters, clusters): + attributes = self._find_cluster_attrs(cluster, merged_ann) + attributes = { + k: v for k, v in attributes.items() if k not in self.conf.ignored_attributes + } + attributes.update(merged_ann.attributes) + merged_ann.attributes = attributes + + new_group_id = find(enumerate(group_map), lambda e: id(cluster) in e[1][0]) + if new_group_id is None: + new_group_id = 0 + else: + new_group_id = new_group_id[0] + 1 + merged_ann.group = new_group_id + + if self.conf.close_distance: + self._check_annotation_distance(t, merged_clusters) + + annotations += merged_clusters + + if self.conf.groups: + self._check_groups(annotations) + + return annotations + + @staticmethod + def match_items(datasets): + item_ids = set((item.id, item.subset) for d in datasets for item in d) + + item_map = {} # id(item) -> (item, id(dataset)) + + matches = OrderedDict() + for item_id, item_subset in sorted(item_ids, key=lambda e: e[0]): + items = {} + for d in datasets: + item = d.get(item_id, subset=item_subset) + if item: + items[id(d)] = item + item_map[id(item)] = (item, id(d)) + matches[(item_id, item_subset)] = items + + return matches, item_map + + def _merge_label_categories(self, sources): + same = True + common = None + for src_categories in sources: + src_cat = src_categories.get(AnnotationType.label) + if common is None: + common = src_cat + elif common != src_cat: + same = False + break + + if same: + return common + + dst_cat = LabelCategories() + for src_id, src_categories in enumerate(sources): + src_cat = src_categories.get(AnnotationType.label) + if src_cat is None: + continue + + for src_label in src_cat.items: + dst_label = dst_cat.find(src_label.name, src_label.parent)[1] + if dst_label is not None: + if dst_label != src_label: + if ( + src_label.parent + and dst_label.parent + and src_label.parent != dst_label.parent + ): + raise ConflictingCategoriesError( + "Can't merge label category %s (from #%s): " + "parent label conflict: %s vs. %s" + % (src_label.name, src_id, src_label.parent, dst_label.parent), + sources=list(range(src_id)), + ) + dst_label.parent = dst_label.parent or src_label.parent + dst_label.attributes |= src_label.attributes + else: + pass + else: + dst_cat.add(src_label.name, src_label.parent, src_label.attributes) + + return dst_cat + + def _merge_point_categories(self, sources, label_cat): + dst_point_cat = PointsCategories() + + for src_id, src_categories in enumerate(sources): + src_label_cat = src_categories.get(AnnotationType.label) + src_point_cat = src_categories.get(AnnotationType.points) + if src_label_cat is None or src_point_cat is None: + continue + + for src_label_id, src_cat in src_point_cat.items.items(): + src_label = src_label_cat.items[src_label_id].name + src_parent_label = src_label_cat.items[src_label_id].parent + dst_label_id = label_cat.find(src_label, src_parent_label)[0] + dst_cat = dst_point_cat.items.get(dst_label_id) + if dst_cat is not None: + if dst_cat != src_cat: + raise ConflictingCategoriesError( + "Can't merge point category for label " + "%s (from #%s): %s vs. %s" % (src_label, src_id, src_cat, dst_cat), + sources=list(range(src_id)), + ) + else: + pass + else: + dst_point_cat.add(dst_label_id, src_cat.labels, src_cat.joints) + + if len(dst_point_cat.items) == 0: + return None + + return dst_point_cat + + def _merge_mask_categories(self, sources, label_cat): + dst_mask_cat = MaskCategories() + + for src_id, src_categories in enumerate(sources): + src_label_cat = src_categories.get(AnnotationType.label) + src_mask_cat = src_categories.get(AnnotationType.mask) + if src_label_cat is None or src_mask_cat is None: + continue + + for src_label_id, src_cat in src_mask_cat.colormap.items(): + src_label = src_label_cat.items[src_label_id].name + src_parent_label = src_label_cat.items[src_label_id].parent + dst_label_id = label_cat.find(src_label, src_parent_label)[0] + dst_cat = dst_mask_cat.colormap.get(dst_label_id) + if dst_cat is not None: + if dst_cat != src_cat: + raise ConflictingCategoriesError( + "Can't merge mask category for label " + "%s (from #%s): %s vs. %s" % (src_label, src_id, src_cat, dst_cat), + sources=list(range(src_id)), + ) + else: + pass + else: + dst_mask_cat.colormap[dst_label_id] = src_cat + + if len(dst_mask_cat.colormap) == 0: + return None + + return dst_mask_cat + + def _merge_categories(self, sources): + dst_categories = {} + + label_cat = self._merge_label_categories(sources) + if label_cat is None: + label_cat = LabelCategories() + dst_categories[AnnotationType.label] = label_cat + + points_cat = self._merge_point_categories(sources, label_cat) + if points_cat is not None: + dst_categories[AnnotationType.points] = points_cat + + mask_cat = self._merge_mask_categories(sources, label_cat) + if mask_cat is not None: + dst_categories[AnnotationType.mask] = mask_cat + + return dst_categories + + def _match_annotations(self, sources): + all_by_type = {} + for s in sources: + src_by_type = {} + for a in s: + src_by_type.setdefault(a.type, []).append(a) + for k, v in src_by_type.items(): + all_by_type.setdefault(k, []).append(v) + + clusters = {} + for k, v in all_by_type.items(): + clusters.setdefault(k, []).extend(self._match_ann_type(k, v)) + + return clusters + + def _make_mergers(self, sources): + def _make(c, **kwargs): + kwargs.update(attr.asdict(self.conf)) + fields = attr.fields_dict(c) + return c(**{k: v for k, v in kwargs.items() if k in fields}, context=self) + + def _for_type(t, **kwargs): + if t is AnnotationType.label: + return _make(LabelMerger, **kwargs) + elif t is AnnotationType.bbox: + return _make(BboxMerger, **kwargs) + elif t is AnnotationType.mask: + return _make(MaskMerger, **kwargs) + elif t is AnnotationType.polygon: + return _make(PolygonMerger, **kwargs) + elif t is AnnotationType.polyline: + return _make(LineMerger, **kwargs) + elif t is AnnotationType.points: + return _make(PointsMerger, **kwargs) + elif t is AnnotationType.caption: + return _make(CaptionsMerger, **kwargs) + elif t is AnnotationType.cuboid_3d: + return _make(Cuboid3dMerger, **kwargs) + elif t is AnnotationType.super_resolution_annotation: + return _make(ImageAnnotationMerger, **kwargs) + elif t is AnnotationType.depth_annotation: + return _make(ImageAnnotationMerger, **kwargs) + elif t is AnnotationType.skeleton: + # to do: add skeletons merge + return _make(ImageAnnotationMerger, **kwargs) + else: + raise NotImplementedError("Type %s is not supported" % t) + + instance_map = {} + for s in sources: + s_instances = find_instances(s) + for inst in s_instances: + inst_bbox = max_bbox( + [ + a + for a in inst + if a.type + in {AnnotationType.polygon, AnnotationType.mask, AnnotationType.bbox} + ] + ) + for ann in inst: + instance_map[id(ann)] = [inst, inst_bbox] + + self._mergers = {t: _for_type(t, instance_map=instance_map) for t in AnnotationType} + + def _match_ann_type(self, t, sources): + return self._mergers[t].match_annotations(sources) + + def _merge_clusters(self, t, clusters): + return self._mergers[t].merge_clusters(clusters) + + @staticmethod + def _find_cluster_groups(clusters): + cluster_groups = [] + visited = set() + for a_idx, cluster_a in enumerate(clusters): + if a_idx in visited: + continue + visited.add(a_idx) + + cluster_group = {id(cluster_a)} + + # find segment groups in the cluster group + a_groups = set(ann.group for ann in cluster_a) + for cluster_b in clusters[a_idx + 1 :]: + b_groups = set(ann.group for ann in cluster_b) + if a_groups & b_groups: + a_groups |= b_groups + + # now we know all the segment groups in this cluster group + # so we can find adjacent clusters + for b_idx, cluster_b in enumerate(clusters[a_idx + 1 :]): + b_idx = a_idx + 1 + b_idx + b_groups = set(ann.group for ann in cluster_b) + if a_groups & b_groups: + cluster_group.add(id(cluster_b)) + visited.add(b_idx) + + if a_groups == {0}: + continue # skip annotations without a group + cluster_groups.append((cluster_group, a_groups)) + return cluster_groups + + def _find_cluster_attrs(self, cluster, ann): + quorum = self.conf.quorum or 0 + + # TODO: when attribute types are implemented, add linear + # interpolation for contiguous values + + attr_votes = {} # name -> { value: score , ... } + for s in cluster: + for name, value in s.attributes.items(): + votes = attr_votes.get(name, {}) + votes[value] = 1 + votes.get(value, 0) + attr_votes[name] = votes + + attributes = {} + for name, votes in attr_votes.items(): + winner, count = max(votes.items(), key=lambda e: e[1]) + if count < quorum: + if sum(votes.values()) < quorum: + # blame provokers + missing_sources = set( + self.get_ann_source(id(a)) + for a in cluster + if s.attributes.get(name) == winner + ) + else: + # blame outliers + missing_sources = set( + self.get_ann_source(id(a)) + for a in cluster + if s.attributes.get(name) != winner + ) + missing_sources = [self._dataset_map[s][1] for s in missing_sources] + self.add_item_error( + FailedAttrVotingError, name, votes, ann, sources=missing_sources + ) + continue + attributes[name] = winner + + return attributes + + def _check_cluster_sources(self, cluster): + if len(cluster) == len(self._dataset_map): + return + + def _has_item(s): + item = self._dataset_map[s][0].get(*self._item_id) + if not item: + return False + if len(item.annotations) == 0: + return False + return True + + missing_sources = set(self._dataset_map) - set(self.get_ann_source(id(a)) for a in cluster) + missing_sources = [self._dataset_map[s][1] for s in missing_sources if _has_item(s)] + if missing_sources: + self.add_item_error(NoMatchingAnnError, cluster[0], sources=missing_sources) + + def _check_annotation_distance(self, t, annotations): + for a_idx, a_ann in enumerate(annotations): + for b_ann in annotations[a_idx + 1 :]: + d = self._mergers[t].distance(a_ann, b_ann) + if self.conf.close_distance < d: + self.add_item_error(AnnotationsTooCloseError, a_ann, b_ann, d) + + def _check_groups(self, annotations): + check_groups = [] + for check_group_raw in self.conf.groups: + check_group = set(l[0] for l in check_group_raw) + optional = set(l[0] for l in check_group_raw if l[1]) + check_groups.append((check_group, optional)) + + def _check_group(group_labels, group): + for check_group, optional in check_groups: + common = check_group & group_labels + real_miss = check_group - common - optional + extra = group_labels - check_group + if common and (extra or real_miss): + self.add_item_error(WrongGroupError, group_labels, check_group, group) + break + + groups = find_instances(annotations) + for group in groups: + group_labels = set() + for ann in group: + if not hasattr(ann, "label"): + continue + label = self._get_label_name(ann.label) + + if ann.group: + group_labels.add(label) + else: + _check_group({label}, [ann]) + + if not group_labels: + continue + _check_group(group_labels, group) + + def _get_label_name(self, label_id): + if label_id is None: + return None + return self._categories[AnnotationType.label].items[label_id].name + + def _get_label_id(self, label, parent=""): + if label is not None: + return self._categories[AnnotationType.label].find(label, parent)[0] + return None + + def _get_src_label_name(self, ann, label_id): + if label_id is None: + return None + item_id = self._ann_map[id(ann)][1] + dataset_id = self._item_map[item_id][1] + return ( + self._dataset_map[dataset_id][0].categories()[AnnotationType.label].items[label_id].name + ) + + def _get_any_label_name(self, ann, label_id): + if label_id is None: + return None + try: + return self._get_src_label_name(ann, label_id) + except KeyError: + return self._get_label_name(label_id) + + def _check_groups_definition(self): + for group in self.conf.groups: + for label, _ in group: + _, entry = self._categories[AnnotationType.label].find(label) + if entry is None: + raise ValueError( + "Datasets do not contain " + "label '%s', available labels %s" + % (label, [i.name for i in self._categories[AnnotationType.label].items]) + ) + + +@attrs(kw_only=True) +class AnnotationMatcher: + _context: Optional[IntersectMerge] = attrib(default=None) + + def match_annotations(self, sources): + raise NotImplementedError() + + +@attrs +class LabelMatcher(AnnotationMatcher): + def distance(self, a, b): + a_label = self._context._get_any_label_name(a, a.label) + b_label = self._context._get_any_label_name(b, b.label) + return a_label == b_label + + def match_annotations(self, sources): + return [sum(sources, [])] + + +@attrs(kw_only=True) +class _ShapeMatcher(AnnotationMatcher): + pairwise_dist = attrib(converter=float, default=0.9) + cluster_dist = attrib(converter=float, default=-1.0) + + def match_annotations(self, sources): + distance = self.distance + label_matcher = self.label_matcher + pairwise_dist = self.pairwise_dist + cluster_dist = self.cluster_dist + + if cluster_dist < 0: + cluster_dist = pairwise_dist + + id_segm = {id(a): (a, id(s)) for s in sources for a in s} + + def _is_close_enough(cluster, extra_id): + # check if whole cluster IoU will not be broken + # when this segment is added + b = id_segm[extra_id][0] + for a_id in cluster: + a = id_segm[a_id][0] + if distance(a, b) < cluster_dist: + return False + return True + + def _has_same_source(cluster, extra_id): + b = id_segm[extra_id][1] + for a_id in cluster: + a = id_segm[a_id][1] + if a == b: + return True + return False + + # match segments in sources, pairwise + adjacent = {i: [] for i in id_segm} # id(sgm) -> [id(adj_sgm1), ...] + for a_idx, src_a in enumerate(sources): + for src_b in sources[a_idx + 1 :]: + matches, _, _, _ = match_segments( + src_a, + src_b, + dist_thresh=pairwise_dist, + distance=distance, + label_matcher=label_matcher, + ) + for a, b in matches: + adjacent[id(a)].append(id(b)) + + # join all segments into matching clusters + clusters = [] + visited = set() + for cluster_idx in adjacent: + if cluster_idx in visited: + continue + + cluster = set() + to_visit = {cluster_idx} + while to_visit: + c = to_visit.pop() + cluster.add(c) + visited.add(c) + + for i in adjacent[c]: + if i in visited: + continue + if 0 < cluster_dist and not _is_close_enough(cluster, i): + continue + if _has_same_source(cluster, i): + continue + + to_visit.add(i) + + clusters.append([id_segm[i][0] for i in cluster]) + + return clusters + + def distance(self, a, b): + return segment_iou(a, b) + + def label_matcher(self, a, b): + a_label = self._context._get_any_label_name(a, a.label) + b_label = self._context._get_any_label_name(b, b.label) + return a_label == b_label + + +@attrs +class BboxMatcher(_ShapeMatcher): + pass + + +@attrs +class PolygonMatcher(_ShapeMatcher): + pass + + +@attrs +class MaskMatcher(_ShapeMatcher): + pass + + +@attrs(kw_only=True) +class PointsMatcher(_ShapeMatcher): + sigma: Optional[list] = attrib(default=None) + instance_map = attrib(converter=dict) + + def distance(self, a, b): + a_bbox = self.instance_map[id(a)][1] + b_bbox = self.instance_map[id(b)][1] + if bbox_iou(a_bbox, b_bbox) <= 0: + return 0 + bbox = mean_bbox([a_bbox, b_bbox]) + return OKS(a, b, sigma=self.sigma, bbox=bbox) + + +@attrs +class LineMatcher(_ShapeMatcher): + def distance(self, a, b): + # Compute inter-line area by using the Trapezoid formulae + # https://en.wikipedia.org/wiki/Trapezoidal_rule + # Normalize by common bbox and get the bbox fill ratio + # Call this ratio the "distance" + + # The box area is an early-exit filter for non-intersected figures + bbox = max_bbox([a, b]) + box_area = bbox[2] * bbox[3] + if not box_area: + return 1 + + def _approx(line, segments): + if len(line) // 2 != segments + 1: + line = approximate_line(line, segments=segments) + return np.reshape(line, (-1, 2)) + + segments = max(len(a.points) // 2, len(b.points) // 2, 5) - 1 + + a = _approx(a.points, segments) + b = _approx(b.points, segments) + dists = np.linalg.norm(a - b, axis=1) + dists = dists[:-1] + dists[1:] + a_steps = np.linalg.norm(a[1:] - a[:-1], axis=1) + b_steps = np.linalg.norm(b[1:] - b[:-1], axis=1) + + # For the common bbox we can't use + # - the AABB (axis-alinged bbox) of a point set + # - the exterior of a point set + # - the convex hull of a point set + # because these soultions won't be correctly normalized. + # The lines can have multiple self-intersections, which can give + # the inter-line area more than internal area of the options above, + # producing the value of the distance outside of the [0; 1] range. + # + # Instead, we can compute the upper boundary for the inter-line + # area based on the maximum point distance and line length. + max_area = np.max(dists) * max(np.sum(a_steps), np.sum(b_steps)) + + area = np.dot(dists, a_steps + b_steps) * 0.5 * 0.5 / max(max_area, 1.0) + + return abs(1 - area) + + +@attrs +class CaptionsMatcher(AnnotationMatcher): + def match_annotations(self, sources): + raise NotImplementedError() + + +@attrs +class Cuboid3dMatcher(_ShapeMatcher): + def distance(self, a, b): + raise NotImplementedError() + + +@attrs +class ImageAnnotationMatcher(AnnotationMatcher): + def match_annotations(self, sources): + raise NotImplementedError() + + +@attrs(kw_only=True) +class AnnotationMerger: + def merge_clusters(self, clusters): + raise NotImplementedError() + + +@attrs(kw_only=True) +class LabelMerger(AnnotationMerger, LabelMatcher): + quorum = attrib(converter=int, default=0) + + def merge_clusters(self, clusters): + assert len(clusters) <= 1 + if len(clusters) == 0: + return [] + + votes = {} # label -> score + for ann in clusters[0]: + label = self._context._get_src_label_name(ann, ann.label) + votes[label] = 1 + votes.get(label, 0) + + merged = [] + for label, count in votes.items(): + if count < self.quorum: + sources = set( + self.get_ann_source(id(a)) + for a in clusters[0] + if label not in [self._context._get_src_label_name(l, l.label) for l in a] + ) + sources = [self._context._dataset_map[s][1] for s in sources] + self._context.add_item_error(FailedLabelVotingError, votes, sources=sources) + continue + + merged.append( + Label( + self._context._get_label_id(label), + attributes={"score": count / len(self._context._dataset_map)}, + ) + ) + + return merged + + +@attrs(kw_only=True) +class _ShapeMerger(AnnotationMerger, _ShapeMatcher): + quorum = attrib(converter=int, default=0) + + def merge_clusters(self, clusters): + return list(filter(lambda x: x is not None, map(self.merge_cluster, clusters))) + + def find_cluster_label(self, cluster): + votes = {} + for s in cluster: + label = self._context._get_src_label_name(s, s.label) + state = votes.setdefault(label, [0, 0]) + state[0] += s.attributes.get("score", 1.0) + state[1] += 1 + + label, (score, count) = max(votes.items(), key=lambda e: e[1][0]) + if count < self.quorum: + self._context.add_item_error(FailedLabelVotingError, votes) + label = None + score = score / len(self._context._dataset_map) + label = self._context._get_label_id(label) + return label, score + + @staticmethod + def _merge_cluster_shape_mean_box_nearest(cluster): + mbbox = Bbox(*mean_bbox(cluster)) + dist = list(segment_iou(mbbox, s) for s in cluster) + # print(cluster) + # print(mbbox, dist) + nearest_pos, _ = max(enumerate(dist), key=lambda e: e[1]) + # print(nearest_pos, dist[nearest_pos], cluster[nearest_pos]) + return cluster[nearest_pos] + + def merge_cluster_shape(self, cluster): + shape = self._merge_cluster_shape_mean_box_nearest(cluster) + shape_score = sum(max(0, self.distance(shape, s)) for s in cluster) / len(cluster) + return shape, shape_score + + def merge_cluster(self, cluster): + for ann in cluster: + if ann.id == 3: + print(cluster) + label, label_score = self.find_cluster_label(cluster) + shape, shape_score = self.merge_cluster_shape(cluster) + # print(shape, shape_score, label, label_score) + # if label is None: + # return None + shape.z_order = max(cluster, key=lambda a: a.z_order).z_order + shape.label = label + shape.attributes["score"] = label_score * shape_score if label is not None else shape_score + + return shape + + +@attrs +class BboxMerger(_ShapeMerger, BboxMatcher): + pass + + +@attrs +class PolygonMerger(_ShapeMerger, PolygonMatcher): + pass + + +@attrs +class MaskMerger(_ShapeMerger, MaskMatcher): + pass + + +@attrs +class PointsMerger(_ShapeMerger, PointsMatcher): + pass + + +@attrs +class LineMerger(_ShapeMerger, LineMatcher): + pass + + +@attrs +class CaptionsMerger(AnnotationMerger, CaptionsMatcher): + pass + + +@attrs +class Cuboid3dMerger(_ShapeMerger, Cuboid3dMatcher): + @staticmethod + def _merge_cluster_shape_mean_box_nearest(cluster): + raise NotImplementedError() + # mbbox = Bbox(*mean_cuboid(cluster)) + # dist = (segment_iou(mbbox, s) for s in cluster) + # nearest_pos, _ = max(enumerate(dist), key=lambda e: e[1]) + # return cluster[nearest_pos] + + def merge_cluster(self, cluster): + label, label_score = self.find_cluster_label(cluster) + shape, shape_score = self.merge_cluster_shape(cluster) + + shape.label = label + shape.attributes["score"] = label_score * shape_score if label is not None else shape_score + + return shape + + +@attrs +class ImageAnnotationMerger(AnnotationMerger, ImageAnnotationMatcher): + pass + + +def match_segments( + a_segms, + b_segms, + distance=segment_iou, + dist_thresh=1.0, + label_matcher=lambda a, b: a.label == b.label, +): + assert callable(distance), distance + assert callable(label_matcher), label_matcher + + a_segms.sort(key=lambda ann: 1 - ann.attributes.get("score", 1)) + b_segms.sort(key=lambda ann: 1 - ann.attributes.get("score", 1)) + + # a_matches: indices of b_segms matched to a bboxes + # b_matches: indices of a_segms matched to b bboxes + a_matches = -np.ones(len(a_segms), dtype=int) + b_matches = -np.ones(len(b_segms), dtype=int) + + distances = np.array([[distance(a, b) for b in b_segms] for a in a_segms]) + + # matches: boxes we succeeded to match completely + # mispred: boxes we succeeded to match, having label mismatch + matches = [] + mispred = [] + + for a_idx, a_segm in enumerate(a_segms): + if len(b_segms) == 0: + break + matched_b = -1 + max_dist = -1 + b_indices = np.argsort( + [not label_matcher(a_segm, b_segm) for b_segm in b_segms], kind="stable" + ) # prioritize those with same label, keep score order + for b_idx in b_indices: + if 0 <= b_matches[b_idx]: # assign a_segm with max conf + continue + d = distances[a_idx, b_idx] + if d < dist_thresh or d <= max_dist: + continue + max_dist = d + matched_b = b_idx + + if matched_b < 0: + continue + a_matches[a_idx] = matched_b + b_matches[matched_b] = a_idx + + b_segm = b_segms[matched_b] + + if label_matcher(a_segm, b_segm): + matches.append((a_segm, b_segm)) + else: + mispred.append((a_segm, b_segm)) + + # *_umatched: boxes of (*) we failed to match + a_unmatched = [a_segms[i] for i, m in enumerate(a_matches) if m < 0] + b_unmatched = [b_segms[i] for i, m in enumerate(b_matches) if m < 0] + + return matches, mispred, a_unmatched, b_unmatched + + +def mean_std(dataset: IDataset): + counter = _MeanStdCounter() + + for item in dataset: + counter.accumulate(item) + + return counter.get_result() + + +class _MeanStdCounter: + """ + Computes unbiased mean and std. dev. for dataset images, channel-wise. + """ + + def __init__(self): + self._stats = {} # (id, subset) -> (pixel count, mean vec, std vec) + + def accumulate(self, item: DatasetItem): + size = item.media.size + if size is None: + log.warning( + "Item %s: can't detect image size, " + "the image will be skipped from pixel statistics", + item.id, + ) + return + count = np.prod(item.media.size) + + image = item.media.data + if len(image.shape) == 2: + image = image[:, :, np.newaxis] + else: + image = image[:, :, :3] + # opencv is much faster than numpy here + mean, std = cv2.meanStdDev(image.astype(np.double) / 255) + + self._stats[(item.id, item.subset)] = (count, mean, std) + + def get_result(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]: + n = len(self._stats) + + if n == 0: + return [0, 0, 0], [0, 0, 0] + + counts = np.empty(n, dtype=np.uint32) + stats = np.empty((n, 2, 3), dtype=np.double) + + for i, v in enumerate(self._stats.values()): + counts[i] = v[0] + stats[i][0] = v[1].reshape(-1) + stats[i][1] = v[2].reshape(-1) + + mean = lambda i, s: s[i][0] + var = lambda i, s: s[i][1] + + # make variance unbiased + np.multiply(np.square(stats[:, 1]), (counts / (counts - 1))[:, np.newaxis], out=stats[:, 1]) + + # Use an online algorithm to: + # - handle different image sizes + # - avoid cancellation problem + _, mean, var = self._compute_stats(stats, counts, mean, var) + return mean * 255, np.sqrt(var) * 255 + + # Implements online parallel computation of sample variance + # https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Parallel_algorithm + @staticmethod + def _pairwise_stats(count_a, mean_a, var_a, count_b, mean_b, var_b): + """ + Computes vector mean and variance. + + Needed do avoid catastrophic cancellation in floating point computations + + Returns: + A tuple (total count, mean, variance) + """ + + # allow long arithmetics + count_a = int(count_a) + count_b = int(count_b) + + delta = mean_b - mean_a + m_a = var_a * (count_a - 1) + m_b = var_b * (count_b - 1) + M2 = m_a + m_b + delta**2 * (count_a * count_b / (count_a + count_b)) + + return (count_a + count_b, mean_a * 0.5 + mean_b * 0.5, M2 / (count_a + count_b - 1)) + + @staticmethod + def _compute_stats(stats, counts, mean_accessor, variance_accessor): + """ + Recursively computes total count, mean and variance, + does O(log(N)) calls. + + Args: + stats: (float array of shape N, 2 * d, d = dimensions of values) + count: (integer array of shape N) + mean_accessor: (function(idx, stats)) to retrieve element mean + variance_accessor: (function(idx, stats)) to retrieve element variance + + Returns: + A tuple (total count, mean, variance) + """ + + m = mean_accessor + v = variance_accessor + n = len(stats) + if n == 1: + return counts[0], m(0, stats), v(0, stats) + if n == 2: + return __class__._pairwise_stats( + counts[0], m(0, stats), v(0, stats), counts[1], m(1, stats), v(1, stats) + ) + h = n // 2 + return __class__._pairwise_stats( + *__class__._compute_stats(stats[:h], counts[:h], m, v), + *__class__._compute_stats(stats[h:], counts[h:], m, v), + ) + + +def compute_image_statistics(dataset: IDataset): + stats = { + "dataset": { + "images count": 0, + "unique images count": 0, + "repeated images count": 0, + "repeated images": [], # [[id1, id2], [id3, id4, id5], ...] + }, + "subsets": {}, + } + + stats_counter = _MeanStdCounter() + unique_counter = _ItemMatcher() + + for item in dataset: + stats_counter.accumulate(item) + unique_counter.process_item(item) + + def _extractor_stats(subset_name, extractor): + sub_counter = _MeanStdCounter() + sub_counter._stats = { + k: v + for k, v in stats_counter._stats.items() + if subset_name and k[1] == subset_name or not subset_name + } + + available = len(sub_counter._stats) != 0 + + stats = { + "images count": len(extractor), + } + + if available: + mean, std = sub_counter.get_result() + + stats.update( + { + "image mean": [float(v) for v in mean[::-1]], + "image std": [float(v) for v in std[::-1]], + } + ) + else: + stats.update( + { + "image mean": "n/a", + "image std": "n/a", + } + ) + return stats + + for subset_name in dataset.subsets(): + stats["subsets"][subset_name] = _extractor_stats( + subset_name, dataset.get_subset(subset_name) + ) + + unique_items = unique_counter.get_result() + repeated_items = [sorted(g) for g in unique_items.values() if 1 < len(g)] + + stats["dataset"].update( + { + "images count": len(dataset), + "unique images count": len(unique_items), + "repeated images count": len(repeated_items), + "repeated images": repeated_items, # [[id1, id2], [id3, id4, id5], ...] + } + ) + + return stats + + +def compute_ann_statistics(dataset: IDataset): + labels = dataset.categories().get(AnnotationType.label, LabelCategories()) + + def get_label(ann): + return labels.items[ann.label].name if ann.label is not None else None + + stats = { + "images count": 0, + "annotations count": 0, + "unannotated images count": 0, + "unannotated images": [], + "annotations by type": { + t.name: { + "count": 0, + } + for t in AnnotationType + }, + "annotations": {}, + } + by_type = stats["annotations by type"] + + attr_template = { + "count": 0, + "values count": 0, + "values present": set(), + "distribution": {}, # value -> (count, total%) + } + label_stat = { + "count": 0, + "distribution": {l.name: [0, 0] for l in labels.items}, # label -> (count, total%) + "attributes": {}, + } + stats["annotations"]["labels"] = label_stat + segm_stat = { + "avg. area": 0, + "area distribution": [], # a histogram with 10 bins + # (min, min+10%), ..., (min+90%, max) -> (count, total%) + "pixel distribution": {l.name: [0, 0] for l in labels.items}, # label -> (count, total%) + } + stats["annotations"]["segments"] = segm_stat + segm_areas = [] + pixel_dist = segm_stat["pixel distribution"] + total_pixels = 0 + + for item in dataset: + if len(item.annotations) == 0: + stats["unannotated images"].append(item.id) + continue + + for ann in item.annotations: + by_type[ann.type.name]["count"] += 1 + + if not hasattr(ann, "label") or ann.label is None: + continue + + if ann.type in {AnnotationType.mask, AnnotationType.polygon, AnnotationType.bbox}: + area = ann.get_area() + segm_areas.append(area) + pixel_dist[get_label(ann)][0] += int(area) + + label_stat["count"] += 1 + label_stat["distribution"][get_label(ann)][0] += 1 + + for name, value in ann.attributes.items(): + if name.lower() in {"occluded", "visibility", "score", "id", "track_id"}: + continue + attrs_stat = label_stat["attributes"].setdefault(name, deepcopy(attr_template)) + attrs_stat["count"] += 1 + attrs_stat["values present"].add(str(value)) + attrs_stat["distribution"].setdefault(str(value), [0, 0])[0] += 1 + + stats["images count"] = len(dataset) + + stats["annotations count"] = sum(t["count"] for t in stats["annotations by type"].values()) + stats["unannotated images count"] = len(stats["unannotated images"]) + + for label_info in label_stat["distribution"].values(): + label_info[1] = label_info[0] / (label_stat["count"] or 1) + + for label_attr in label_stat["attributes"].values(): + label_attr["values count"] = len(label_attr["values present"]) + label_attr["values present"] = sorted(label_attr["values present"]) + for attr_info in label_attr["distribution"].values(): + attr_info[1] = attr_info[0] / (label_attr["count"] or 1) + + # numpy.sum might be faster, but could overflow with large datasets. + # Python's int can transparently mutate to be of indefinite precision (long) + total_pixels = sum(int(a) for a in segm_areas) + + segm_stat["avg. area"] = total_pixels / (len(segm_areas) or 1.0) + + for label_info in segm_stat["pixel distribution"].values(): + label_info[1] = label_info[0] / (total_pixels or 1) + + if len(segm_areas) != 0: + hist, bins = np.histogram(segm_areas) + segm_stat["area distribution"] = [ + { + "min": float(bin_min), + "max": float(bin_max), + "count": int(c), + "percent": int(c) / len(segm_areas), + } + for c, (bin_min, bin_max) in zip(hist, zip(bins[:-1], bins[1:])) + ] + + return stats + + +@attrs +class DistanceComparator: + iou_threshold = attrib(converter=float, default=0.5) + + def match_annotations(self, item_a, item_b): + return {t: self._match_ann_type(t, item_a, item_b) for t in AnnotationType} + + def _match_ann_type(self, t, *args): + # pylint: disable=no-value-for-parameter + if t == AnnotationType.label: + return self.match_labels(*args) + elif t == AnnotationType.bbox: + return self.match_boxes(*args) + elif t == AnnotationType.polygon: + return self.match_polygons(*args) + elif t == AnnotationType.mask: + return self.match_masks(*args) + elif t == AnnotationType.points: + return self.match_points(*args) + elif t == AnnotationType.polyline: + return self.match_lines(*args) + # pylint: enable=no-value-for-parameter + else: + raise NotImplementedError("Unexpected annotation type %s" % t) + + @staticmethod + def _get_ann_type(t, item): + return [a for a in item.annotations if a.type == t] + + def match_labels(self, item_a, item_b): + a_labels = set(a.label for a in self._get_ann_type(AnnotationType.label, item_a)) + b_labels = set(a.label for a in self._get_ann_type(AnnotationType.label, item_b)) + + matches = a_labels & b_labels + a_unmatched = a_labels - b_labels + b_unmatched = b_labels - a_labels + return matches, a_unmatched, b_unmatched + + def _match_segments(self, t, item_a, item_b): + a_boxes = self._get_ann_type(t, item_a) + b_boxes = self._get_ann_type(t, item_b) + return match_segments(a_boxes, b_boxes, dist_thresh=self.iou_threshold) + + def match_polygons(self, item_a, item_b): + return self._match_segments(AnnotationType.polygon, item_a, item_b) + + def match_masks(self, item_a, item_b): + return self._match_segments(AnnotationType.mask, item_a, item_b) + + def match_boxes(self, item_a, item_b): + return self._match_segments(AnnotationType.bbox, item_a, item_b) + + def match_points(self, item_a, item_b): + a_points = self._get_ann_type(AnnotationType.points, item_a) + b_points = self._get_ann_type(AnnotationType.points, item_b) + + instance_map = {} + for s in [item_a.annotations, item_b.annotations]: + s_instances = find_instances(s) + for inst in s_instances: + inst_bbox = max_bbox(inst) + for ann in inst: + instance_map[id(ann)] = [inst, inst_bbox] + matcher = PointsMatcher(instance_map=instance_map) + + return match_segments( + a_points, b_points, dist_thresh=self.iou_threshold, distance=matcher.distance + ) + + def match_lines(self, item_a, item_b): + a_lines = self._get_ann_type(AnnotationType.polyline, item_a) + b_lines = self._get_ann_type(AnnotationType.polyline, item_b) + + matcher = LineMatcher() + + return match_segments( + a_lines, b_lines, dist_thresh=self.iou_threshold, distance=matcher.distance + ) + + +def match_items_by_id(a: IDataset, b: IDataset): + a_items = set((item.id, item.subset) for item in a) + b_items = set((item.id, item.subset) for item in b) + + matches = a_items & b_items + matches = [([m], [m]) for m in matches] + a_unmatched = a_items - b_items + b_unmatched = b_items - a_items + return matches, a_unmatched, b_unmatched + + +def match_items_by_image_hash(a: IDataset, b: IDataset): + a_hash = find_unique_images(a) + b_hash = find_unique_images(b) + + a_items = set(a_hash) + b_items = set(b_hash) + + matches = a_items & b_items + a_unmatched = a_items - b_items + b_unmatched = b_items - a_items + + matches = [(a_hash[h], b_hash[h]) for h in matches] + a_unmatched = set(i for h in a_unmatched for i in a_hash[h]) + b_unmatched = set(i for h in b_unmatched for i in b_hash[h]) + + return matches, a_unmatched, b_unmatched + + +class _ItemMatcher: + @staticmethod + def _default_item_hash(item: DatasetItem): + if not item.media or not item.media.has_data: + if item.media and item.media.path: + return hash(item.media.path) + + log.warning( + "Item (%s, %s) has no image " "info, counted as unique", item.id, item.subset + ) + return None + + # Disable B303:md5, because the hash is not used in a security context + return hashlib.md5(item.media.data.tobytes()).hexdigest() # nosec + + def __init__(self, item_hash: Optional[Callable] = None): + self._hash = item_hash or self._default_item_hash + + # hash -> [(id, subset), ...] + self._unique: Dict[str, Set[Tuple[str, str]]] = {} + + def process_item(self, item: DatasetItem): + h = self._hash(item) + if h is None: + h = str(id(item)) # anything unique + + self._unique.setdefault(h, set()).add((item.id, item.subset)) + + def get_result(self): + return self._unique + + +def find_unique_images(dataset: IDataset, item_hash: Optional[Callable] = None): + matcher = _ItemMatcher(item_hash=item_hash) + for item in dataset: + matcher.process_item(item) + return matcher.get_result() + + +def match_classes(a: CategoriesInfo, b: CategoriesInfo): + a_label_cat = a.get(AnnotationType.label, LabelCategories()) + b_label_cat = b.get(AnnotationType.label, LabelCategories()) + + a_labels = set(c.name for c in a_label_cat) + b_labels = set(c.name for c in b_label_cat) + + matches = a_labels & b_labels + a_unmatched = a_labels - b_labels + b_unmatched = b_labels - a_labels + return matches, a_unmatched, b_unmatched + + +@attrs +class ExactComparator: + match_images: bool = attrib(kw_only=True, default=False) + ignored_fields = attrib(kw_only=True, factory=set, validator=default_if_none(set)) + ignored_attrs = attrib(kw_only=True, factory=set, validator=default_if_none(set)) + ignored_item_attrs = attrib(kw_only=True, factory=set, validator=default_if_none(set)) + + _test: TestCase = attrib(init=False) + errors: list = attrib(init=False) + + def __attrs_post_init__(self): + self._test = TestCase() + self._test.maxDiff = None + + def _match_items(self, a, b): + if self.match_images: + return match_items_by_image_hash(a, b) + else: + return match_items_by_id(a, b) + + def _compare_categories(self, a, b): + test = self._test + errors = self.errors + + try: + test.assertEqual(sorted(a, key=lambda t: t.value), sorted(b, key=lambda t: t.value)) + except AssertionError as e: + errors.append({"type": "categories", "message": str(e)}) + + if AnnotationType.label in a: + try: + test.assertEqual( + a[AnnotationType.label].items, + b[AnnotationType.label].items, + ) + except AssertionError as e: + errors.append({"type": "labels", "message": str(e)}) + if AnnotationType.mask in a: + try: + test.assertEqual( + a[AnnotationType.mask].colormap, + b[AnnotationType.mask].colormap, + ) + except AssertionError as e: + errors.append({"type": "colormap", "message": str(e)}) + if AnnotationType.points in a: + try: + test.assertEqual( + a[AnnotationType.points].items, + b[AnnotationType.points].items, + ) + except AssertionError as e: + errors.append({"type": "points", "message": str(e)}) + + def _compare_annotations(self, a, b): + ignored_fields = self.ignored_fields + ignored_attrs = self.ignored_attrs + + a_fields = {k: None for k in a.as_dict() if k in ignored_fields} + b_fields = {k: None for k in b.as_dict() if k in ignored_fields} + if "attributes" not in ignored_fields: + a_fields["attributes"] = filter_dict(a.attributes, ignored_attrs) + b_fields["attributes"] = filter_dict(b.attributes, ignored_attrs) + + result = a.wrap(**a_fields) == b.wrap(**b_fields) + + return result + + def _compare_items(self, item_a, item_b): + test = self._test + + a_id = (item_a.id, item_a.subset) + b_id = (item_b.id, item_b.subset) + + matched = [] + unmatched = [] + errors = [] + + try: + test.assertEqual( + filter_dict(item_a.attributes, self.ignored_item_attrs), + filter_dict(item_b.attributes, self.ignored_item_attrs), + ) + except AssertionError as e: + errors.append({"type": "item_attr", "a_item": a_id, "b_item": b_id, "message": str(e)}) + + b_annotations = item_b.annotations[:] + for ann_a in item_a.annotations: + ann_b_candidates = [x for x in item_b.annotations if x.type == ann_a.type] + + ann_b = find( + enumerate(self._compare_annotations(ann_a, x) for x in ann_b_candidates), + lambda x: x[1], + ) + if ann_b is None: + unmatched.append( + { + "item": a_id, + "source": "a", + "ann": str(ann_a), + } + ) + continue + else: + ann_b = ann_b_candidates[ann_b[0]] + + b_annotations.remove(ann_b) # avoid repeats + matched.append({"a_item": a_id, "b_item": b_id, "a": str(ann_a), "b": str(ann_b)}) + + for ann_b in b_annotations: + unmatched.append({"item": b_id, "source": "b", "ann": str(ann_b)}) + + return matched, unmatched, errors + + def compare_datasets(self, a, b): + self.errors = [] + errors = self.errors + + self._compare_categories(a.categories(), b.categories()) + + matched = [] + unmatched = [] + + matches, a_unmatched, b_unmatched = self._match_items(a, b) + + if a.categories().get(AnnotationType.label) != b.categories().get(AnnotationType.label): + return matched, unmatched, a_unmatched, b_unmatched, errors + + _dist = lambda s: len(s[1]) + len(s[2]) + for a_ids, b_ids in matches: + # build distance matrix + match_status = {} # (a_id, b_id): [matched, unmatched, errors] + a_matches = {a_id: None for a_id in a_ids} + b_matches = {b_id: None for b_id in b_ids} + + for a_id in a_ids: + item_a = a.get(*a_id) + candidates = {} + + for b_id in b_ids: + item_b = b.get(*b_id) + + i_m, i_um, i_err = self._compare_items(item_a, item_b) + candidates[b_id] = [i_m, i_um, i_err] + + if len(i_um) == 0: + a_matches[a_id] = b_id + b_matches[b_id] = a_id + matched.extend(i_m) + errors.extend(i_err) + break + + match_status[a_id] = candidates + + # assign + for a_id in a_ids: + if len(b_ids) == 0: + break + + # find the closest, ignore already assigned + matched_b = a_matches[a_id] + if matched_b is not None: + continue + min_dist = -1 + for b_id in b_ids: + if b_matches[b_id] is not None: + continue + d = _dist(match_status[a_id][b_id]) + if d < min_dist and 0 <= min_dist: + continue + min_dist = d + matched_b = b_id + + if matched_b is None: + continue + a_matches[a_id] = matched_b + b_matches[matched_b] = a_id + + m = match_status[a_id][matched_b] + matched.extend(m[0]) + unmatched.extend(m[1]) + errors.extend(m[2]) + + a_unmatched |= set(a_id for a_id, m in a_matches.items() if not m) + b_unmatched |= set(b_id for b_id, m in b_matches.items() if not m) + + return matched, unmatched, a_unmatched, b_unmatched, errors From b76178617a8f93964b8206e760503b1c57490c9b Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 22 Jun 2024 07:41:21 +0530 Subject: [PATCH 045/301] removed `agreement_score_threshold` and renamed `job` to `jobs` in `consensus_job_per_segment` --- cvat-core/src/server-response-types.ts | 3 +- cvat-core/src/session-implementation.ts | 9 +--- cvat-core/src/session.ts | 25 ++--------- cvat-ui/src/actions/tasks-actions.ts | 11 ++--- .../consensus-configuration-form.tsx | 34 +++------------ .../create-task-page/create-task-content.tsx | 38 +++++++---------- cvat-ui/src/components/job-item/job-item.tsx | 4 +- .../consensus-configuration-editor.tsx | 41 ------------------- cvat-ui/src/components/task-page/details.tsx | 23 ++--------- cvat/apps/engine/backup.py | 3 +- ...job_id_task_consensus_jobs_per_segment.py} | 9 +--- cvat/apps/engine/models.py | 3 +- cvat/apps/engine/serializers.py | 27 ++++-------- cvat/apps/engine/task.py | 2 +- 14 files changed, 50 insertions(+), 182 deletions(-) delete mode 100644 cvat-ui/src/components/task-page/consensus-configuration-editor.tsx rename cvat/apps/engine/migrations/{0079_job_parent_job_id_task_agreement_score_threshold_and_more.py => 0079_job_parent_job_id_task_consensus_jobs_per_segment.py} (65%) diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index 2501dc7f865..1addb82a0c9 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -120,8 +120,7 @@ export interface SerializedTask { subset: string; updated_date: string; url: string; - consensus_job_per_segment: number; - agreement_score_threshold: number; + consensus_jobs_per_segment: number; } export interface SerializedJob { diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 1b2c2af3423..27f48bb8246 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -406,7 +406,6 @@ export function implementTask(Task) { bugTracker: 'bug_tracker', projectId: 'project_id', assignee: 'assignee_id', - agreementScoreThreshold: 'agreement_score_threshold', }); if (taskData.assignee_id) { @@ -476,12 +475,8 @@ export function implementTask(Task) { taskSpec.source_storage = this.sourceStorage.toJSON(); } - if (this.consensusJobPerSegment) { - taskSpec.consensus_job_per_segment = this.consensusJobPerSegment; - } - - if (this.agreementScoreThreshold) { - taskSpec.agreement_score_threshold = this.agreementScoreThreshold; + if (this.consensusJobsPerSegment) { + taskSpec.consensus_jobs_per_segment = this.consensusJobsPerSegment; } const taskDataSpec = { diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 6f2cce5bf81..66ed676bdf5 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -672,8 +672,7 @@ export class Task extends Session { public readonly organization: number | null; public readonly progress: { count: number; completed: number }; public readonly jobs: Job[]; - public readonly consensusJobPerSegment: number; - public agreementScoreThreshold: number; + public readonly consensusJobsPerSegment: number; public readonly startFrame: number; public readonly stopFrame: number; @@ -728,9 +727,7 @@ export class Task extends Session { cloud_storage_id: undefined, sorting_method: undefined, files: undefined, - consensus_job_per_segment: undefined, - agreement_score_threshold: undefined, - + consensus_jobs_per_segment: undefined, quality_settings: undefined, }; @@ -915,22 +912,8 @@ export class Task extends Session { copyData: { get: () => data.copy_data, }, - consensusJobPerSegment: { - get: () => data.consensus_job_per_segment, - }, - agreementScoreThreshold: { - get: () => data.agreement_score_threshold, - set: (value: number) => { - if (typeof value !== 'number') { - throw new ArgumentError( - `Agreement Score Threshold value must be a Number. But ${typeof value} has been got.`, - ); - } - - updateTrigger.update('agreementScoreThreshold'); - data.agreement_score_threshold = value; - console.log(data); - }, + consensusJobsPerSegment: { + get: () => data.consensus_jobs_per_segment, }, labels: { get: () => [...data.labels], diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index fcce3d5de64..24db01d5a6a 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -213,8 +213,7 @@ ThunkAction, {}, {}, AnyAction> { sorting_method: data.advanced.sortingMethod, source_storage: new Storage(data.advanced.sourceStorage || { location: StorageLocation.LOCAL }).toJSON(), target_storage: new Storage(data.advanced.targetStorage || { location: StorageLocation.LOCAL }).toJSON(), - consensus_job_per_segment: 0, - agreement_score_threshold: 0, + consensus_jobs_per_segment: 0, }; if (data.projectId) { @@ -253,12 +252,10 @@ ThunkAction, {}, {}, AnyAction> { if (data.cloudStorageId) { description.cloud_storage_id = data.cloudStorageId; } - if (data.consensus.consensusJobPerSegment) { - description.consensus_job_per_segment = +data.consensus.consensusJobPerSegment; - } - if (data.consensus.agreementScoreThreshold) { - description.agreement_score_threshold = data.consensus.agreementScoreThreshold; + if (data.consensus.consensusJobsPerSegment) { + description.consensus_jobs_per_segment = +data.consensus.consensusJobsPerSegment; } + const taskInstance = new cvat.classes.Task(description); taskInstance.clientFiles = data.files.local; taskInstance.serverFiles = data.files.share.concat(data.files.cloudStorage); diff --git a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx index 18fe16945a3..e0ae420302e 100644 --- a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx @@ -10,13 +10,11 @@ import Form, { FormInstance, RuleObject } from 'antd/lib/form'; import { Store } from 'antd/lib/form/interface'; export interface ConsensusConfiguration { - consensusJobPerSegment?: number; - agreementScoreThreshold?: number; + consensusJobsPerSegment: number; } const initialValues: ConsensusConfiguration = { - consensusJobPerSegment: 0, - agreementScoreThreshold: 0, + consensusJobsPerSegment: 0, }; interface Props { @@ -95,11 +93,11 @@ class ConsensusConfigurationForm extends React.PureComponent { } /* eslint-disable class-methods-use-this */ - private renderConsensusJobPerSegment(): JSX.Element { + private renderConsensusJobsPerSegment(): JSX.Element { return ( { ); } - private renderAgreementScoreThreshold(): JSX.Element { - return ( - - - - ); - } - public render(): JSX.Element { return (
- - {this.renderConsensusJobPerSegment()} - - - {this.renderAgreementScoreThreshold()} + + {this.renderConsensusJobsPerSegment()}
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 ad1a5373a1f..6eff34611d6 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 @@ -85,7 +85,9 @@ const defaultState: State = { useProjectSourceStorage: true, useProjectTargetStorage: true, }, - consensus: {}, + consensus: { + consensusJobsPerSegment: 0, + }, labels: [], files: { local: [], @@ -757,6 +759,17 @@ class CreateTaskContent extends React.PureComponent + + + ); + } + private renderSubsetBlock(): JSX.Element | null { const { projectId, subset } = this.state; @@ -895,26 +908,6 @@ class CreateTaskContent extends React.PureComponent - Consensus configuration, - children: ( - - ), - }]} - /> - - ); - } - private renderFooterSingleTask(): JSX.Element { const { uploadFileErrorMessage, loading, statusInProgressTask: status } = this.state; @@ -1004,9 +997,8 @@ class CreateTaskContent extends React.PureComponent {many ? this.renderFooterMultiTasks() : this.renderFooterSingleTask() } diff --git a/cvat-ui/src/components/job-item/job-item.tsx b/cvat-ui/src/components/job-item/job-item.tsx index 0db430958d5..d7cb58fca1d 100644 --- a/cvat-ui/src/components/job-item/job-item.tsx +++ b/cvat-ui/src/components/job-item/job-item.tsx @@ -115,12 +115,12 @@ function JobItem(props: Props): JSX.Element { const frameCountPercent = ((job.frameCount / (task.size || 1)) * 100).toFixed(0); const frameCountPercentRepresentation = frameCountPercent === '0' ? '<1' : frameCountPercent; let jobName = `Job #${job.id}`; - if (task.consensusJobPerSegment && job.type !== JobType.GROUND_TRUTH) { + if (task.consensusJobsPerSegment && job.type !== JobType.GROUND_TRUTH) { jobName = job.parentJobId === null ? `Normal Job #${job.id}` : `Consensus Job #${job.id}`; } let consensusJob: Job[] = []; - if (task.consensusJobPerSegment) { + if (task.consensusJobsPerSegment) { consensusJob = task.jobs.filter((eachJob: Job) => eachJob.parentJobId === id).reverse(); } const consensusJobView: React.JSX.Element[] = consensusJob.map((eachJob: Job) => ( diff --git a/cvat-ui/src/components/task-page/consensus-configuration-editor.tsx b/cvat-ui/src/components/task-page/consensus-configuration-editor.tsx deleted file mode 100644 index 924923f9965..00000000000 --- a/cvat-ui/src/components/task-page/consensus-configuration-editor.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (C) 2019-2022 Intel Corporation -// Copyright (C) 2022-2024 CVAT.ai Corporation -// -// SPDX-License-Identifier: MIT - -import React, { useState } from 'react'; - -import { Col, Row } from 'antd/lib/grid'; -import Text from 'antd/lib/typography/Text'; - -import { Task } from 'cvat-core-wrapper'; - -interface Props { - instance: Task; - onChange: (agreementScoreThreshold: number) => void; -} - -export default function ConsensusConfigurationEditorComponent(props: Props): JSX.Element { - const { instance, onChange } = props; - - const [agreementScoreThreshold, setAgreementScoreThreshold] = useState(instance.agreementScoreThreshold); - - const onChangeValue = (value: string): void => { - const val = parseFloat(value); - setAgreementScoreThreshold(val); - onChange(val); - }; - - return ( - - - - Agreement Score Threshold - - - {agreementScoreThreshold} - - - - ); -} diff --git a/cvat-ui/src/components/task-page/details.tsx b/cvat-ui/src/components/task-page/details.tsx index e925b4091f1..1a0d227c5b9 100644 --- a/cvat-ui/src/components/task-page/details.tsx +++ b/cvat-ui/src/components/task-page/details.tsx @@ -19,7 +19,6 @@ import { cancelInferenceAsync } from 'actions/models-actions'; import { CombinedState, ActiveInference } from 'reducers'; import UserSelector from './user-selector'; import BugTrackerEditor from './bug-tracker-editor'; -import ConsensusConfigurationEditor from './consensus-configuration-editor'; import LabelsEditorComponent from '../labels-editor/labels-editor'; import ProjectSubsetField from '../create-task-page/project-subset-field'; @@ -68,7 +67,7 @@ const core = getCore(); interface State { name: string; subset: string; - consensusJobPerSegment: number; + consensusJobsPerSegment: number; } type Props = DispatchToProps & StateToProps & OwnProps; @@ -80,7 +79,7 @@ class DetailsComponent extends React.PureComponent { this.state = { name: taskInstance.name, subset: taskInstance.subset, - consensusJobPerSegment: taskInstance.consensusJobPerSegment, + consensusJobsPerSegment: taskInstance.consensusJobsPerSegment, }; } @@ -95,9 +94,9 @@ class DetailsComponent extends React.PureComponent { } private renderTaskName(): JSX.Element { - const { name, consensusJobPerSegment } = this.state; + const { name, consensusJobsPerSegment } = this.state; const { task: taskInstance, onUpdateTask } = this.props; - const taskName = name + (consensusJobPerSegment > 0 ? ' (Consensus Based Annotation)' : ''); + const taskName = name + (consensusJobsPerSegment > 0 ? ' (Consensus Based Annotation)' : ''); return ( { }} /> </Col> - { - taskInstance.consensusJobPerSegment > 0 && ( - <Col span={12}> - <ConsensusConfigurationEditor - instance={taskInstance} - onChange={(value) => { - taskInstance.agreementScoreThreshold = value; - onUpdateTask(taskInstance); - }} - /> - </Col> - ) - } - <Col span={10}> <AutomaticAnnotationProgress activeInference={activeInference} diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index 6895d55bc81..cd5ca3547f8 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -183,8 +183,7 @@ def _prepare_task_meta(self, task): 'status', 'subset', 'labels', - 'consensus_job_per_segment', - 'agreement_score_threshold', + 'consensus_jobs_per_segment', } return self._prepare_meta(allowed_fields, task) diff --git a/cvat/apps/engine/migrations/0079_job_parent_job_id_task_agreement_score_threshold_and_more.py b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment.py similarity index 65% rename from cvat/apps/engine/migrations/0079_job_parent_job_id_task_agreement_score_threshold_and_more.py rename to cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment.py index 0c67ee5a237..c7230f6161e 100644 --- a/cvat/apps/engine/migrations/0079_job_parent_job_id_task_agreement_score_threshold_and_more.py +++ b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-06-18 23:57 +# Generated by Django 4.2.11 on 2024-06-22 02:00 from django.db import migrations, models @@ -17,12 +17,7 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name="task", - name="agreement_score_threshold", - field=models.FloatField(blank=True, default=0), - ), - migrations.AddField( - model_name="task", - name="consensus_job_per_segment", + name="consensus_jobs_per_segment", field=models.IntegerField(blank=True, default=0), ), ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index bb01bf3c0dc..1ae3a793aa0 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -418,8 +418,7 @@ class Task(TimestampedModel): blank=True, on_delete=models.SET_NULL, related_name='+') target_storage = models.ForeignKey('Storage', null=True, default=None, blank=True, on_delete=models.SET_NULL, related_name='+') - consensus_job_per_segment = models.IntegerField(default=0, blank=True) - agreement_score_threshold = models.FloatField(default=0, blank=True) + consensus_jobs_per_segment = models.IntegerField(default=0, blank=True) # Extend default permission model class Meta: diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 0a460727ddc..5ad8a610226 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1097,8 +1097,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='*') - consensus_job_per_segment = serializers.ReadOnlyField(required=False) - agreement_score_threshold = serializers.FloatField(required=False) + consensus_jobs_per_segment = serializers.ReadOnlyField(required=False) class Meta: model = models.Task @@ -1107,7 +1106,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', - 'consensus_job_per_segment', 'agreement_score_threshold' + 'consensus_jobs_per_segment', ) read_only_fields = fields extra_kwargs = { @@ -1123,14 +1122,13 @@ class TaskWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): project_id = serializers.IntegerField(required=False, allow_null=True) target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) - consensus_job_per_segment = serializers.IntegerField(required=False) - agreement_score_threshold = serializers.FloatField(required=False, allow_null=True) + consensus_jobs_per_segment = serializers.IntegerField(required=False) 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', 'consensus_job_per_segment', 'agreement_score_threshold' + 'target_storage', 'source_storage', 'consensus_jobs_per_segment', ) write_once_fields = ('overlap', 'segment_size') @@ -1190,14 +1188,10 @@ def update(self, instance, validated_data): instance.bug_tracker) instance.subset = validated_data.get('subset', instance.subset) labels = validated_data.get('label_set', []) - instance.agreement_score_threshold = validated_data.get('agreement_score_threshold', instance.agreement_score_threshold) if instance.project_id is None: LabelSerializer.update_labels(labels, parent_instance=instance) - if instance.agreement_score_threshold < 0 or instance.agreement_score_threshold > 1: - raise serializers.ValidationError('Agreement score threshold must be in [0, 1]') - validated_project_id = validated_data.get('project_id') if validated_project_id is not None and validated_project_id != instance.project_id: project = models.Project.objects.get(id=validated_project_id) @@ -1315,19 +1309,12 @@ def validate(self, attrs): if sublabels != target_project_sublabel_names.get(label): raise serializers.ValidationError('All task or project label names must be mapped to the target project') - consensus_job_per_segment = attrs.get('consensus_job_per_segment', self.instance.consensus_job_per_segment if self.instance else None) - agreement_score_threshold = attrs.get('agreement_score_threshold', self.instance.agreement_score_threshold if self.instance else None) + consensus_jobs_per_segment = attrs.get('consensus_jobs_per_segment', self.instance.consensus_jobs_per_segment if self.instance else None) - if consensus_job_per_segment is None: + if consensus_jobs_per_segment is None: raise serializers.ValidationError("Consensus job per segment can't be None") - if agreement_score_threshold is None: - raise serializers.ValidationError("Agreement score threshold can't be None") - - if agreement_score_threshold < 0 or agreement_score_threshold > 1: - raise serializers.ValidationError("Agreement score threshold should be in the range [0, 1]") - - if consensus_job_per_segment == 1 or consensus_job_per_segment < 0: + if consensus_jobs_per_segment == 1 or consensus_jobs_per_segment < 0: raise serializers.ValidationError("Consensus job per segment should be greater than or equal to 0 and not 1") return attrs diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index ded9deed827..4314bff587c 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -173,7 +173,7 @@ def _save_task_to_db(db_task: models.Task, *, job_file_mapping: Optional[JobFile db_job.make_dirs() # consensus jobs use the same `db_segment` as the normal job, thus data not duplicated in backups, exports - for _ in range(db_task.consensus_job_per_segment): + for _ in range(db_task.consensus_jobs_per_segment): consensus_db_job = models.Job(segment=db_segment, parent_job_id=db_job.id) consensus_db_job.save() consensus_db_job.make_dirs() From 57220b74a96283007506f1e789c615c70fa1cfb6 Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Sat, 22 Jun 2024 07:42:07 +0530 Subject: [PATCH 046/301] renamed `_match_segments` to `match_segments` --- cvat/apps/quality_control/quality_reports.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cvat/apps/quality_control/quality_reports.py b/cvat/apps/quality_control/quality_reports.py index 467f29c95a5..9d625810ec0 100644 --- a/cvat/apps/quality_control/quality_reports.py +++ b/cvat/apps/quality_control/quality_reports.py @@ -639,7 +639,7 @@ def _convert_shape(self, shape, *, index): return converted -def _match_segments( +def match_segments( a_segms, b_segms, distance=dm.ops.segment_iou, @@ -1047,7 +1047,7 @@ def _match_segments( if label_matcher: extra_args["label_matcher"] = label_matcher - returned_values = _match_segments( + returned_values = match_segments( a_objs, b_objs, distance=distance, @@ -1294,7 +1294,7 @@ def _distance(a: dm.Points, b: dm.Points) -> float: a_points = np.reshape(a.points, (-1, 2)) b_points = np.reshape(b.points, (-1, 2)) - matches, mismatches, a_extra, b_extra = _match_segments( + matches, mismatches, a_extra, b_extra = match_segments( range(len(a_points)), range(len(b_points)), distance=lambda ai, bi: _OKS( @@ -1582,7 +1582,7 @@ def _group_distance(gt_group_id, ds_group_id): union = len(gt_groups[gt_group_id]) + len(ds_groups[ds_group_id]) - intersection return intersection / (union or 1) - matches, mismatches, gt_unmatched, ds_unmatched = _match_segments( + matches, mismatches, gt_unmatched, ds_unmatched = match_segments( list(gt_groups), list(ds_groups), distance=_group_distance, From ffce7514f9d62badad23995a10d13b6e9778f94a Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Sat, 22 Jun 2024 07:42:55 +0530 Subject: [PATCH 047/301] removed "consensus_job_per_segment" and "agreement_score_threshold" from task data in cvat-sdk --- cvat-sdk/cvat_sdk/core/proxies/tasks.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cvat-sdk/cvat_sdk/core/proxies/tasks.py b/cvat-sdk/cvat_sdk/core/proxies/tasks.py index 2ed2f1d46ea..509928f4dd5 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/tasks.py +++ b/cvat-sdk/cvat_sdk/core/proxies/tasks.py @@ -96,8 +96,6 @@ def upload_data( "filename_pattern", "cloud_storage_id", "server_files_exclude", - "consensus_job_per_segment", - "agreement_score_threshold", ], ) ) From 7b631c294f730198c27dad7463c40077d6b17ea7 Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Sun, 30 Jun 2024 12:15:51 +0530 Subject: [PATCH 048/301] renamed `consensusJobPerSegment` to `consensusJobsPerSegment` --- cvat-ui/src/components/actions-menu/actions-menu.tsx | 6 +++--- cvat-ui/src/containers/actions-menu/actions-menu.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cvat-ui/src/components/actions-menu/actions-menu.tsx b/cvat-ui/src/components/actions-menu/actions-menu.tsx index ad03c278bfa..5c3d61c3c2b 100644 --- a/cvat-ui/src/components/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/components/actions-menu/actions-menu.tsx @@ -25,7 +25,7 @@ interface Props { taskDimension: DimensionType; backupIsActive: boolean; mergingIsActive: boolean; - consensusJobPerSegment: number; + consensusJobsPerSegment: number; onClickMenu: (params: MenuInfo) => void; } @@ -49,7 +49,7 @@ function ActionsMenuComponent(props: Props): JSX.Element { inferenceIsActive, backupIsActive, mergingIsActive, - consensusJobPerSegment, + consensusJobsPerSegment, onClickMenu, } = props; @@ -134,7 +134,7 @@ function ActionsMenuComponent(props: Props): JSX.Element { </Menu.Item> ), 50]); - if (consensusJobPerSegment) { + if (consensusJobsPerSegment) { menuItems.push([( <React.Fragment key={Actions.MERGE_TASK_CONSENSUS_JOBS}> <Menu.Item diff --git a/cvat-ui/src/containers/actions-menu/actions-menu.tsx b/cvat-ui/src/containers/actions-menu/actions-menu.tsx index f24cc1cc4ae..d52fbfd6f35 100644 --- a/cvat-ui/src/containers/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/containers/actions-menu/actions-menu.tsx @@ -135,7 +135,7 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): taskDimension={taskInstance.dimension} backupIsActive={backupIsActive} mergingIsActive={mergingIsActive} - consensusJobPerSegment={taskInstance.consensusJobPerSegment} + consensusJobsPerSegment={taskInstance.consensusJobsPerSegment} /> ); } From e20398ef38cd6854151ff3b8247e925e860683e6 Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Tue, 2 Jul 2024 15:46:21 +0530 Subject: [PATCH 049/301] added consensus settings to the backend and connected UI in the front end to configure consensus properties through single tab --- cvat-core/src/api-implementation.ts | 15 +- cvat-core/src/api.ts | 9 + cvat-core/src/consensus-settings.ts | 81 +++++++++ cvat-core/src/index.ts | 10 +- cvat-core/src/server-proxy.ts | 50 +++++- cvat-core/src/server-response-types.ts | 12 +- cvat-ui/src/actions/consensus-actions.ts | 56 +++++++ cvat-ui/src/actions/tasks-actions.ts | 51 ------ .../components/actions-menu/actions-menu.tsx | 31 +--- .../analytics-page/analytics-page.tsx | 7 + .../task-consensus-component.tsx | 140 ++++++++++++++++ .../components/consensus/consensus-modal.tsx | 115 +++++++++++++ .../consensus/consensus-settings-form.tsx | 156 ++++++++++++++++++ cvat-ui/src/components/consensus/styles.scss | 59 +++++++ cvat-ui/src/components/cvat-app.tsx | 2 + .../containers/actions-menu/actions-menu.tsx | 18 +- cvat-ui/src/cvat-core-wrapper.ts | 2 + cvat-ui/src/reducers/consensus-reducer.ts | 91 ++++++++++ cvat-ui/src/reducers/index.ts | 13 +- cvat-ui/src/reducers/notifications-reducer.ts | 5 +- cvat-ui/src/reducers/root-reducer.ts | 2 + cvat-ui/src/reducers/tasks-reducer.ts | 49 ------ cvat/apps/consensus/apps.py | 5 + .../apps/consensus/migrations/0001_initial.py | 69 ++++++++ cvat/apps/consensus/models.py | 26 ++- cvat/apps/consensus/permissions.py | 112 +++++++++++++ .../consensus/rules/consensus_settings.rego | 104 ++++++++++++ cvat/apps/consensus/serializers.py | 46 ++++++ cvat/apps/consensus/signals.py | 28 ++++ cvat/apps/consensus/urls.py | 17 ++ cvat/apps/consensus/views.py | 120 +++++++++++++- cvat/settings/base.py | 1 + cvat/urls.py | 3 + 33 files changed, 1350 insertions(+), 155 deletions(-) create mode 100644 cvat-core/src/consensus-settings.ts create mode 100644 cvat-ui/src/actions/consensus-actions.ts create mode 100644 cvat-ui/src/components/analytics-page/task-consensus/task-consensus-component.tsx create mode 100644 cvat-ui/src/components/consensus/consensus-modal.tsx create mode 100644 cvat-ui/src/components/consensus/consensus-settings-form.tsx create mode 100644 cvat-ui/src/components/consensus/styles.scss create mode 100644 cvat-ui/src/reducers/consensus-reducer.ts create mode 100644 cvat/apps/consensus/migrations/0001_initial.py create mode 100644 cvat/apps/consensus/permissions.py create mode 100644 cvat/apps/consensus/rules/consensus_settings.rego create mode 100644 cvat/apps/consensus/serializers.py create mode 100644 cvat/apps/consensus/signals.py create mode 100644 cvat/apps/consensus/urls.py diff --git a/cvat-core/src/api-implementation.ts b/cvat-core/src/api-implementation.ts index 0bafcd2f32d..f6053d2dfa8 100644 --- a/cvat-core/src/api-implementation.ts +++ b/cvat-core/src/api-implementation.ts @@ -31,7 +31,7 @@ import Webhook from './webhook'; import { ArgumentError } from './exceptions'; import { AnalyticsReportFilter, QualityConflictsFilter, QualityReportsFilter, - QualitySettingsFilter, SerializedAsset, + SettingsFilter, SerializedAsset, } from './server-response-types'; import QualityReport from './quality-report'; import QualityConflict, { ConflictSeverity } from './quality-conflict'; @@ -42,6 +42,7 @@ import { listActions, registerAction, runActions } from './annotations-actions'; import { JobType } from './enums'; import { PaginatedResource } from './core-types'; import CVATCore from '.'; +import ConsensusSettings from './consensus-settings'; function implementationMixin(func: Function, implementation: Function): void { Object.assign(func, { implementation }); @@ -505,7 +506,7 @@ export default function implementAPI(cvat: CVATCore): CVATCore { return mergedConflicts; }); - implementationMixin(cvat.analytics.quality.settings.get, async (filter: QualitySettingsFilter) => { + implementationMixin(cvat.analytics.quality.settings.get, async (filter: SettingsFilter) => { checkFilter(filter, { taskID: isInteger, }); @@ -515,6 +516,16 @@ export default function implementAPI(cvat: CVATCore): CVATCore { const settings = await serverProxy.analytics.quality.settings.get(params); return new QualitySettings({ ...settings }); }); + implementationMixin(cvat.consensus.settings.get, async (filter: SettingsFilter) => { + checkFilter(filter, { + taskID: isInteger, + }); + + const params = fieldsToSnakeCase(filter); + + const settings = await serverProxy.consensus.settings.get(params); + return new ConsensusSettings({ ...settings }); + }); implementationMixin(cvat.analytics.performance.reports, async (filter: AnalyticsReportFilter) => { checkFilter(filter, { jobID: isInteger, diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index 20e9ce8577f..04354439ae9 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -374,6 +374,14 @@ function build(): CVATCore { }, }, }, + consensus: { + settings: { + async get(filter = {}) { + const result = await PluginRegistry.apiWrapper(cvat.consensus.settings.get, filter); + return result; + }, + }, + }, classes: { User, Project: implementProject(Project), @@ -418,6 +426,7 @@ function build(): CVATCore { cvat.organizations = Object.freeze(cvat.organizations); cvat.webhooks = Object.freeze(cvat.webhooks); cvat.analytics = Object.freeze(cvat.analytics); + cvat.consensus = Object.freeze(cvat.consensus); cvat.classes = Object.freeze(cvat.classes); cvat.utils = Object.freeze(cvat.utils); diff --git a/cvat-core/src/consensus-settings.ts b/cvat-core/src/consensus-settings.ts new file mode 100644 index 00000000000..53ea1ad4dc7 --- /dev/null +++ b/cvat-core/src/consensus-settings.ts @@ -0,0 +1,81 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { SerializedConsensusSettingsData } from './server-response-types'; +import PluginRegistry from './plugins'; +import serverProxy from './server-proxy'; + +export default class ConsensusSettings { + #id: number; + #task: number; + #iouThreshold: number; + #quorum: number; + #agreementScoreThreshold: number; + + constructor(initialData: SerializedConsensusSettingsData) { + this.#id = initialData.id; + this.#task = initialData.task; + this.#iouThreshold = initialData.iou_threshold; + this.#agreementScoreThreshold = initialData.agreement_score_threshold; + this.#quorum = initialData.quorum; + } + + get id(): number { + return this.#id; + } + + get task(): number { + return this.#task; + } + + get iouThreshold(): number { + return this.#iouThreshold; + } + + set iouThreshold(newVal: number) { + this.#iouThreshold = newVal; + } + + get quorum(): number { + return this.#quorum; + } + + set quorum(newVal: number) { + this.#quorum = newVal; + } + + get agreementScoreThreshold(): number { + return this.#agreementScoreThreshold; + } + + set agreementScoreThreshold(newVal: number) { + this.#agreementScoreThreshold = newVal; + } + + public toJSON(): SerializedConsensusSettingsData { + const result: SerializedConsensusSettingsData = { + iou_threshold: this.#iouThreshold, + quorum: this.#quorum, + agreement_score_threshold: this.#agreementScoreThreshold, + }; + + return result; + } + + public async save(): Promise<ConsensusSettings> { + const result = await PluginRegistry.apiWrapper.call(this, ConsensusSettings.prototype.save); + return result; + } +} + +Object.defineProperties(ConsensusSettings.prototype.save, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation() { + const result = await serverProxy.consensus.settings.update(this.id, this.toJSON()); + return new ConsensusSettings(result); + }, + }, +}); diff --git a/cvat-core/src/index.ts b/cvat-core/src/index.ts index 402ea4a69d9..7829de062cb 100644 --- a/cvat-core/src/index.ts +++ b/cvat-core/src/index.ts @@ -2,8 +2,9 @@ // // SPDX-License-Identifier: MIT +import ConsensusSettings from 'consensus-settings'; import { - AnalyticsReportFilter, QualityConflictsFilter, QualityReportsFilter, QualitySettingsFilter, + AnalyticsReportFilter, QualityConflictsFilter, QualityReportsFilter, SettingsFilter, } from './server-response-types'; import PluginRegistry from './plugins'; import serverProxy from './server-proxy'; @@ -131,12 +132,17 @@ export default interface CVATCore { webhooks: { get: any; }; + consensus: { + settings: { + get: (filter: SettingsFilter) => Promise<ConsensusSettings>; + }; + } analytics: { quality: { reports: (filter: QualityReportsFilter) => Promise<PaginatedResource<QualityReport>>; conflicts: (filter: QualityConflictsFilter) => Promise<QualityConflict[]>; settings: { - get: (filter: QualitySettingsFilter) => Promise<QualitySettings>; + get: (filter: SettingsFilter) => Promise<QualitySettings>; }; }; performance: { diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index 928f44f33e9..eee69c2addc 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -16,8 +16,9 @@ import { SerializedAbout, SerializedRemoteFile, SerializedUserAgreement, SerializedRegister, JobsFilter, SerializedJob, SerializedGuide, SerializedAsset, SerializedAPISchema, SerializedInvitationData, SerializedCloudStorage, SerializedFramesMetaData, SerializedCollection, - SerializedQualitySettingsData, APIQualitySettingsFilter, SerializedQualityConflictData, APIQualityConflictsFilter, + SerializedQualitySettingsData, APISettingsFilter, SerializedQualityConflictData, APIQualityConflictsFilter, SerializedQualityReportData, APIQualityReportsFilter, SerializedAnalyticsReport, APIAnalyticsReportFilter, + SerializedConsensusSettingsData, } from './server-response-types'; import { PaginatedResource } from './core-types'; import { Storage } from './storage'; @@ -2358,7 +2359,7 @@ async function createAsset(file: File, guideId: number): Promise<SerializedAsset } async function getQualitySettings( - filter: APIQualitySettingsFilter, + filter: APISettingsFilter, ): Promise<SerializedQualitySettingsData> { const { backendAPI } = config; @@ -2393,6 +2394,44 @@ async function updateQualitySettings( } } +async function getConsensusSettings( + filter: APISettingsFilter, +): Promise<SerializedConsensusSettingsData> { + const { backendAPI } = config; + + try { + const response = await Axios.get(`${backendAPI}/consensus/settings`, { + params: { + ...filter, + }, + }); + + return response.data.results[0]; + } catch (errorData) { + throw generateError(errorData); + } +} + +async function updateConsensusSettings( + settingsID: number, + settingsData: SerializedConsensusSettingsData, +): Promise<SerializedConsensusSettingsData> { + const params = enableOrganization(); + const { backendAPI } = config; + + console.log(settingsData); + + try { + const response = await Axios.patch(`${backendAPI}/consensus/settings/${settingsID}`, settingsData, { + params, + }); + + return response.data; + } catch (errorData) { + throw generateError(errorData); + } +} + async function getQualityConflicts( filter: APIQualityConflictsFilter, ): Promise<SerializedQualityConflictData[]> { @@ -2691,4 +2730,11 @@ export default Object.freeze({ }), }), }), + + consensus: Object.freeze({ + settings: Object.freeze({ + get: getConsensusSettings, + update: updateConsensusSettings, + }), + }), }); diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index 1addb82a0c9..81cbc722f84 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -235,10 +235,10 @@ export interface SerializedOrganization { contact?: SerializedOrganizationContact, } -export interface APIQualitySettingsFilter extends APICommonFilterParams { +export interface APISettingsFilter extends APICommonFilterParams { task_id?: number; } -export type QualitySettingsFilter = Camelized<APIQualitySettingsFilter>; +export type SettingsFilter = Camelized<APISettingsFilter>; export interface SerializedQualitySettingsData { id?: number; @@ -257,6 +257,14 @@ export interface SerializedQualitySettingsData { compare_attributes?: boolean; } +export interface SerializedConsensusSettingsData { + id?: number; + task?: number; + agreement_score_threshold?: number; + quorum?: number; + iou_threshold?: number; +} + export interface APIQualityConflictsFilter extends APICommonFilterParams { report_id?: number; } diff --git a/cvat-ui/src/actions/consensus-actions.ts b/cvat-ui/src/actions/consensus-actions.ts new file mode 100644 index 00000000000..b6028c315b2 --- /dev/null +++ b/cvat-ui/src/actions/consensus-actions.ts @@ -0,0 +1,56 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; +import { ConsensusSettings } from 'cvat-core-wrapper'; + +export enum ConsensusActionTypes { + OPEN_CONSENSUS_MODAL = 'OPEN_CONSENSUS_MODAL', + CLOSE_CONSENSUS_MODAL = 'CLOSE_CONSENSUS_MODAL', + SET_FETCHING = 'SET_FETCHING', + SET_CONSENSUS_SETTINGS = 'SET_CONSENSUS_SETTINGS', + MERGE_CONSENSUS_JOBS = 'MERGE_CONSENSUS_JOBS', + MERGE_CONSENSUS_JOBS_SUCCESS = 'MERGE_CONSENSUS_JOBS_SUCCESS', + MERGE_CONSENSUS_JOBS_FAILED = 'MERGE_CONSENSUS_JOBS_FAILED', +} + +export const consensusActions = { + openConsensusModal: (instance: any) => ( + createAction(ConsensusActionTypes.OPEN_CONSENSUS_MODAL, { instance }) + ), + closeConsensusModal: (instance: any) => ( + createAction(ConsensusActionTypes.CLOSE_CONSENSUS_MODAL, { instance }) + ), + setFetching: (fetching: boolean) => ( + createAction(ConsensusActionTypes.SET_FETCHING, { fetching }) + ), + setConsensusSettings: (consensusSettings: ConsensusSettings) => ( + createAction(ConsensusActionTypes.SET_CONSENSUS_SETTINGS, { consensusSettings }) + ), + mergeTaskConsensusJobs: (taskID: number) => ( + createAction(ConsensusActionTypes.MERGE_CONSENSUS_JOBS, { taskID }) + ), + mergeTaskConsensusJobsSuccess: (taskID: number) => ( + createAction(ConsensusActionTypes.MERGE_CONSENSUS_JOBS_SUCCESS, { taskID }) + ), + mergeTaskConsensusJobsFailed: (taskID: number, error: any) => ( + createAction(ConsensusActionTypes.MERGE_CONSENSUS_JOBS_FAILED, { taskID, error }) + ), +}; + +export const mergeTaskConsensusJobsAsync = ( + taskInstance: any, +): ThunkAction => async (dispatch) => { + try { + dispatch(consensusActions.mergeTaskConsensusJobs(taskInstance.id)); + await taskInstance.mergeConsensusJobs(); + } catch (error) { + dispatch(consensusActions.mergeTaskConsensusJobsFailed(taskInstance.id, error)); + return; + } + + dispatch(consensusActions.mergeTaskConsensusJobsSuccess(taskInstance.id)); +}; + +export type ConsensusActions = ActionUnion<typeof consensusActions>; diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index b9f7eeb9a86..24db01d5a6a 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -27,9 +27,6 @@ export enum TasksActionTypes { GET_TASK_PREVIEW_SUCCESS = 'GET_TASK_PREVIEW_SUCCESS', GET_TASK_PREVIEW_FAILED = 'GET_TASK_PREVIEW_FAILED', UPDATE_TASK_IN_STATE = 'UPDATE_TASK_IN_STATE', - MERGE_TASK_CONSENSUS = 'AGGREGATE_TASK_CONSENSUS', - MERGE_TASK_CONSENSUS_SUCCESS = 'AGGREGATE_TASK_CONSENSUS_SUCCESS', - MERGE_TASK_CONSENSUS_FAILED = 'AGGREGATE_TASK_CONSENSUS_FAILED', } function getTasks(query: Partial<TasksQuery>, updateQuery: boolean): AnyAction { @@ -123,40 +120,6 @@ function deleteTaskFailed(taskID: number, error: any): AnyAction { return action; } -function mergeTaskConsensusJobs(taskID: number): AnyAction { - const action = { - type: TasksActionTypes.MERGE_TASK_CONSENSUS, - payload: { - taskID, - }, - }; - - return action; -} - -function mergeTaskConsensusJobsSuccess(taskID: number): AnyAction { - const action = { - type: TasksActionTypes.MERGE_TASK_CONSENSUS_SUCCESS, - payload: { - taskID, - }, - }; - - return action; -} - -function mergeTaskConsensusJobsFailed(taskID: number, error: any): AnyAction { - const action = { - type: TasksActionTypes.MERGE_TASK_CONSENSUS_FAILED, - payload: { - taskID, - error, - }, - }; - - return action; -} - export function deleteTaskAsync(taskInstance: any): ThunkAction<Promise<void>, {}, {}, AnyAction> { return async (dispatch: ActionCreator<Dispatch>): Promise<void> => { try { @@ -171,20 +134,6 @@ export function deleteTaskAsync(taskInstance: any): ThunkAction<Promise<void>, { }; } -export function mergeTaskConsensusJobsAsync(taskInstance: any): ThunkAction<Promise<void>, {}, {}, AnyAction> { - return async (dispatch: ActionCreator<Dispatch>): Promise<void> => { - try { - dispatch(mergeTaskConsensusJobs(taskInstance.id)); - await taskInstance.mergeConsensusJobs(); - } catch (error) { - dispatch(mergeTaskConsensusJobsFailed(taskInstance.id, error)); - return; - } - - dispatch(mergeTaskConsensusJobsSuccess(taskInstance.id)); - }; -} - function createTaskFailed(error: any): AnyAction { const action = { type: TasksActionTypes.CREATE_TASK_FAILED, diff --git a/cvat-ui/src/components/actions-menu/actions-menu.tsx b/cvat-ui/src/components/actions-menu/actions-menu.tsx index 5c3d61c3c2b..1fbdeb53be8 100644 --- a/cvat-ui/src/components/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/components/actions-menu/actions-menu.tsx @@ -24,7 +24,6 @@ interface Props { inferenceIsActive: boolean; taskDimension: DimensionType; backupIsActive: boolean; - mergingIsActive: boolean; consensusJobsPerSegment: number; onClickMenu: (params: MenuInfo) => void; } @@ -38,7 +37,7 @@ export enum Actions { OPEN_BUG_TRACKER = 'open_bug_tracker', BACKUP_TASK = 'backup_task', VIEW_ANALYTICS = 'view_analytics', - MERGE_TASK_CONSENSUS_JOBS = 'merge_task_consensus_jobs', + SHOW_TASK_CONSENSUS_CONFIGURATION = 'show_task_consensus_configuration', } function ActionsMenuComponent(props: Props): JSX.Element { @@ -48,7 +47,6 @@ function ActionsMenuComponent(props: Props): JSX.Element { bugTracker, inferenceIsActive, backupIsActive, - mergingIsActive, consensusJobsPerSegment, onClickMenu, } = props; @@ -75,19 +73,6 @@ function ActionsMenuComponent(props: Props): JSX.Element { }, okText: 'Delete', }); - } else if (params.key === Actions.MERGE_TASK_CONSENSUS_JOBS) { - Modal.confirm({ - title: `The consensus jobs in task ${taskID} will be merged`, - content: 'Exisitng annotations in normal jobs will be lost. Continue?', - className: 'cvat-modal-confirm-delete-task', - onOk: () => { - onClickMenu(params); - }, - okButtonProps: { - type: 'primary', - }, - okText: 'Merge', - }); } else { onClickMenu(params); } @@ -136,15 +121,11 @@ function ActionsMenuComponent(props: Props): JSX.Element { if (consensusJobsPerSegment) { menuItems.push([( - <React.Fragment key={Actions.MERGE_TASK_CONSENSUS_JOBS}> - <Menu.Item - key={Actions.MERGE_TASK_CONSENSUS_JOBS} - disabled={mergingIsActive} - icon={mergingIsActive && <LoadingOutlined id='cvat-backup-task-loading' />} - > - Merge consensus jobs - </Menu.Item> - </React.Fragment> + <Menu.Item + key={Actions.SHOW_TASK_CONSENSUS_CONFIGURATION} + > + Consensus Configuration + </Menu.Item> ), 55]); } diff --git a/cvat-ui/src/components/analytics-page/analytics-page.tsx b/cvat-ui/src/components/analytics-page/analytics-page.tsx index a10ac8525e7..97855789c43 100644 --- a/cvat-ui/src/components/analytics-page/analytics-page.tsx +++ b/cvat-ui/src/components/analytics-page/analytics-page.tsx @@ -21,12 +21,14 @@ import CVATLoadingSpinner from 'components/common/loading-spinner'; import GoBackButton from 'components/common/go-back-button'; import AnalyticsOverview, { DateIntervals } from './analytics-performance'; import TaskQualityComponent from './task-quality/task-quality-component'; +import TaskConsensusAnalyticsComponent from './task-consensus/task-consensus-component'; const core = getCore(); enum AnalyticsTabs { OVERVIEW = 'overview', QUALITY = 'quality', + CONSENSUS = 'consensus', } function getTabFromHash(): AnalyticsTabs { @@ -292,6 +294,11 @@ function AnalyticsPage(): JSX.Element { key: AnalyticsTabs.QUALITY, label: 'Quality', children: <TaskQualityComponent task={instance} onJobUpdate={onJobUpdate} />, + }] : []), + ...((instanceType === 'task' && instance.consensusJobsPerSegment) ? [{ + key: AnalyticsTabs.CONSENSUS, + label: 'Consensus', + children: <TaskConsensusAnalyticsComponent task={instance} onJobUpdate={onJobUpdate} />, }] : [])]} /> ); diff --git a/cvat-ui/src/components/analytics-page/task-consensus/task-consensus-component.tsx b/cvat-ui/src/components/analytics-page/task-consensus/task-consensus-component.tsx new file mode 100644 index 00000000000..639fe2a32bd --- /dev/null +++ b/cvat-ui/src/components/analytics-page/task-consensus/task-consensus-component.tsx @@ -0,0 +1,140 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { Row } from 'antd/lib/grid'; +import Text from 'antd/lib/typography/Text'; +import CVATLoadingSpinner from 'components/common/loading-spinner'; +import JobItem from 'components/job-item/job-item'; +import { + Job, JobType, QualityReport, ConsensusSettings, Task, +} from 'cvat-core-wrapper'; +import React, { useReducer } from 'react'; +import { ActionUnion, createAction } from 'utils/redux'; +import EmptyGtJob from '../task-quality/empty-job'; +import GtConflicts from '../task-quality/gt-conflicts'; +import Issues from '../task-quality/issues'; +import JobList from '../task-quality/job-list'; + +interface Props { + task: Task; + onJobUpdate: (job: Job) => void; +} + +interface State { + fetching: boolean; + taskReport: QualityReport | null; + jobsReports: QualityReport[]; + consensusSettings: { + settings: ConsensusSettings | null; + fetching: boolean; + visible: boolean; + }, +} + +enum ReducerActionType { + SET_FETCHING = 'SET_FETCHING', + SET_TASK_REPORT = 'SET_TASK_REPORT', + SET_JOBS_REPORTS = 'SET_JOBS_REPORTS', +} + +export const reducerActions = { + setFetching: (fetching: boolean) => ( + createAction(ReducerActionType.SET_FETCHING, { fetching }) + ), + setTaskReport: (qualityReport: QualityReport) => ( + createAction(ReducerActionType.SET_TASK_REPORT, { qualityReport }) + ), + setJobsReports: (qualityReports: QualityReport[]) => ( + createAction(ReducerActionType.SET_JOBS_REPORTS, { qualityReports }) + ), +}; + +const reducer = (state: State, action: ActionUnion<typeof reducerActions>): State => { + if (action.type === ReducerActionType.SET_FETCHING) { + return { + ...state, + fetching: action.payload.fetching, + }; + } + + if (action.type === ReducerActionType.SET_TASK_REPORT) { + return { + ...state, + taskReport: action.payload.qualityReport, + }; + } + + if (action.type === ReducerActionType.SET_JOBS_REPORTS) { + return { + ...state, + jobsReports: action.payload.qualityReports, + }; + } + + return state; +}; + +function TaskConsensusAnalyticsComponent(props: Props): JSX.Element { + const { task, onJobUpdate } = props; + + const [state] = useReducer(reducer, { + fetching: true, + taskReport: null, + jobsReports: [], + consensusSettings: { + settings: null, + fetching: true, + visible: false, + }, + }); + + // const gtJob = task.jobs.find((job: Job) => job.type === JobType.GROUND_TRUTH); + const gtJob = task.jobs.find((job: Job) => job.type !== JobType.GROUND_TRUTH); + + const { + fetching, taskReport, jobsReports, + } = state; + + return ( + <div className='cvat-task-quality-page'> + {(() => { + if (fetching) { + return <CVATLoadingSpinner size='large' />; + } if (gtJob) { + return ( + <> + <Row gutter={16}> + <GtConflicts taskReport={taskReport} /> + <Issues task={task} /> + </Row> + {!(gtJob && gtJob.stage === 'acceptance' && gtJob.state === 'completed') && ( + <Row> + <Text type='secondary' className='cvat-task-quality-reports-hint'> + Quality reports are not computed unless the GT job is in the  + <strong>completed state</strong> +  and  + <strong>acceptance stage.</strong> + </Text> + </Row> + )} + <Row> + <JobItem job={gtJob} task={task} onJobUpdate={onJobUpdate} /> + </Row> + <Row> + <JobList jobsReports={jobsReports} task={task} /> + </Row> + </> + ); + } + return ( + <Row justify='center'> + <EmptyGtJob taskID={task.id} /> + </Row> + ); + })()} + </div> + ); +} + +export default React.memo(TaskConsensusAnalyticsComponent); diff --git a/cvat-ui/src/components/consensus/consensus-modal.tsx b/cvat-ui/src/components/consensus/consensus-modal.tsx new file mode 100644 index 00000000000..dafc802b7e7 --- /dev/null +++ b/cvat-ui/src/components/consensus/consensus-modal.tsx @@ -0,0 +1,115 @@ +// Copyright (c) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React, { useEffect, useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import Modal from 'antd/lib/modal'; +import Notification from 'antd/lib/notification'; +import Text from 'antd/lib/typography/Text'; +import Form from 'antd/lib/form'; +import { CombinedState } from 'reducers'; +import { getCore } from 'cvat-core-wrapper'; + +import { consensusActions, mergeTaskConsensusJobsAsync } from 'actions/consensus-actions'; + +import CVATLoadingSpinner from 'components/common/loading-spinner'; +import { Button } from 'antd/lib'; +import { Divider, notification } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; +import ConsensusSettingsForm from './consensus-settings-form'; + +const core = getCore(); + +function ConsensusModal(): JSX.Element { + const dispatch = useDispatch(); + const [form] = Form.useForm(); + + const instance = useSelector((state: CombinedState) => state.consensus?.taskInstance); + + const fetching = useSelector((state: CombinedState) => state.consensus?.fetching); + + const consensusSettings = useSelector((state: CombinedState) => state.consensus?.consensusSettings); + + const mergingIsActive = useSelector((state: CombinedState) => state.consensus?.mergingConsensus[instance?.id]); + + function handleError(error: Error): void { + notification.error({ + description: error.toString(), + message: 'Could not fetch consensus settings.', + }); + } + + useEffect(() => { + if (instance) { + dispatch(consensusActions.setFetching(true)); + + const settingsRequest = core.consensus.settings.get({ taskID: instance.id }); + + Promise.all([settingsRequest]) + .then(([settings]) => { + dispatch(consensusActions.setConsensusSettings(settings)); + }) + .catch(handleError) + .finally(() => { + dispatch(consensusActions.setFetching(false)); + }); + } + }, [instance?.id]); + + const closeModal = (): void => { + form.resetFields(); + dispatch(consensusActions.closeConsensusModal(instance)); + }; + + const handleMerge = useCallback(() => { + dispatch(mergeTaskConsensusJobsAsync(instance)); + Notification.info({ + message: 'Merging consensus jobs...', + description: 'Merge Report will be available as the merging process is completed successfully.', + className: 'cvat-notification-notice-export-backup-start', + }); + }, [instance]); + + return ( + <Modal + title={<Text> Consensus Configurations </Text>} + open={!!instance} + className='cvat-modal-export-task custom-modal-center-title' + destroyOnClose + confirmLoading={fetching} + footer={null} + onCancel={closeModal} + > + {fetching && instance ? ( + <CVATLoadingSpinner size='large' /> + ) : ( + <ConsensusSettingsForm + settings={consensusSettings} + setConsensusSettings={(settings) => dispatch(consensusActions.setConsensusSettings(settings))} + /> + )} + <Divider /> + <Form + name='Consensus' + form={form} + layout='vertical' + onFinish={handleMerge} + className='consensus-modal-form' + > + <Button + type='default' + htmlType='submit' + disabled={mergingIsActive} + icon={mergingIsActive && <LoadingOutlined />} + > + {' '} + Merge Consensus Task + </Button> + </Form> + </Modal> + ); +} + +export default React.memo(ConsensusModal); diff --git a/cvat-ui/src/components/consensus/consensus-settings-form.tsx b/cvat-ui/src/components/consensus/consensus-settings-form.tsx new file mode 100644 index 00000000000..63d225692f8 --- /dev/null +++ b/cvat-ui/src/components/consensus/consensus-settings-form.tsx @@ -0,0 +1,156 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React, { useCallback } from 'react'; +import { QuestionCircleOutlined } from '@ant-design/icons/lib/icons'; +import Text from 'antd/lib/typography/Text'; +import InputNumber from 'antd/lib/input-number'; +import { Col, Row } from 'antd/lib/grid'; +import Form from 'antd/lib/form'; +import CVATTooltip from 'components/common/cvat-tooltip'; +import { ConsensusSettings } from 'cvat-core-wrapper'; +import { Button } from 'antd/lib'; +import notification from 'antd/lib/notification'; + +interface Props { + settings: ConsensusSettings | null; + setConsensusSettings: (settings: ConsensusSettings) => void; +} + +export default function ConsensusSettingsForm(props: Props): JSX.Element | null { + const { settings, setConsensusSettings } = props; + + if (!settings) { + return ( + <Text>No quality settings</Text> + ); + } + + const [form] = Form.useForm(); + + const initialValues = { + iouThreshold: settings.iouThreshold * 100, + agreementScoreThreshold: settings.agreementScoreThreshold * 100, + quorum: settings.quorum, + }; + + const onSave = useCallback(async () => { + try { + if (settings) { + const values = await form.validateFields(); + + settings.iouThreshold = values.iouThreshold / 100; + settings.quorum = values.quorum; + settings.agreementScoreThreshold = values.agreementScoreThreshold / 100; + + try { + notification.info({ + message: 'Updating Consensus Settings', + }); + const responseSettings = await settings.save(); + setConsensusSettings(responseSettings); + } catch (error: unknown) { + notification.error({ + message: 'Could not save consensus settings', + description: typeof Error === 'object' ? (error as object).toString() : '', + }); + throw error; + } + await settings.save(); + notification.info({ + message: 'Consensus Settings have been updated', + }); + } + + return settings; + } catch (e) { + return false; + } + }, [settings]); + + const generalTooltip = ( + <div className='cvat-analytics-settings-tooltip-inner'> + <Text> + Min overlap threshold(IoU) is used for distinction between matched / unmatched shapes. + </Text> + <Text> + Agreement score threshold is used for distinction between strong / weak consensus. + </Text> + <Text> + Quorum is used for voting a label and attribute results to be counted + </Text> + </div> + ); + + return ( + <Form + form={form} + layout='vertical' + initialValues={initialValues} + > + <Row className='cvat-quality-settings-title'> + <Text strong> + Consensus Settings + </Text> + <CVATTooltip title={generalTooltip} className='cvat-analytics-tooltip' overlayStyle={{ maxWidth: '500px' }}> + <QuestionCircleOutlined + style={{ opacity: 0.5 }} + /> + </CVATTooltip> + </Row> + <Row> + <Col span={12}> + <Form.Item + name='iouThreshold' + label='Min overlap threshold (%)' + rules={[{ required: true }]} + > + <InputNumber min={0} max={100} precision={0} /> + </Form.Item> + </Col> + <Col span={12}> + <Form.Item + name='agreementScoreThreshold' + label='Agreement Score threshold (%)' + rules={[{ required: true }]} + > + <InputNumber min={0} max={100} precision={0} /> + </Form.Item> + </Col> + <Col span={12}> + <Form.Item + name='quorum' + label='Quorum' + rules={[{ required: true }]} + > + <InputNumber min={0} max={10} precision={0} /> + </Form.Item> + </Col> + </Row> + <Row> + <Form.Item className='consensus-settings-form-reset-button'> + <Col span={9}> + <Button + type='default' // or any other type according to your design + onClick={() => { + form.resetFields(); + }} + > + Reset Settings + </Button> + </Col> + <Col span={9}> + <Button + type='default' + onClick={onSave} + > + Save + </Button> + </Col> + </Form.Item> + </Row> + </Form> + ); +} diff --git a/cvat-ui/src/components/consensus/styles.scss b/cvat-ui/src/components/consensus/styles.scss new file mode 100644 index 00000000000..301579532c1 --- /dev/null +++ b/cvat-ui/src/components/consensus/styles.scss @@ -0,0 +1,59 @@ +@import '../export-backup/styles'; + +.consensus-modal-form { + display: flex; + flex-direction: column; + align-items: center; + + .ant-btn-default { + background-color: #0059ff; + border: none; + } +} + +.custom-modal-center-title { + .ant-modal-header { + text-align: center; + } + + .ant-modal-title { + flex: 1; + text-align: center; + } +} + +.consensus-settings-form { + .ant-form-item { + display: flex; + justify-content: space-between; + + .ant-col { + display: flex; + justify-content: center; + + .ant-btn { + padding: 10px 0; + + &.reset-button { + background-color: #f5f5f5; + color: #000; + border: 1px solid #d9d9d9; + + &:hover { + background-color: #e6e6e6; + } + } + + &.save-button { + background-color: #1890ff; + color: #fff; + border: none; + + &:hover { + background-color: #40a9ff; + } + } + } + } + } +} \ No newline at end of file diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index 0d023468125..247af54b638 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -31,6 +31,7 @@ import ExportDatasetModal from 'components/export-dataset/export-dataset-modal'; import ExportBackupModal from 'components/export-backup/export-backup-modal'; import ImportDatasetModal from 'components/import-dataset/import-dataset-modal'; import ImportBackupModal from 'components/import-backup/import-backup-modal'; +import ConsensusModal from 'components/consensus/consensus-modal'; import JobsPageComponent from 'components/jobs-page/jobs-page'; import ModelsPageComponent from 'components/models-page/models-page'; @@ -542,6 +543,7 @@ class CVATApplication extends React.PureComponent<CVATAppProps & RouteComponentP </Switch> <ExportDatasetModal /> <ExportBackupModal /> + <ConsensusModal /> <ImportDatasetModal /> <ImportBackupModal /> <InvitationWatcher /> diff --git a/cvat-ui/src/containers/actions-menu/actions-menu.tsx b/cvat-ui/src/containers/actions-menu/actions-menu.tsx index d52fbfd6f35..a64dd59503c 100644 --- a/cvat-ui/src/containers/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/containers/actions-menu/actions-menu.tsx @@ -13,11 +13,11 @@ import { CombinedState } from 'reducers'; import { modelsActions } from 'actions/models-actions'; import { deleteTaskAsync, - mergeTaskConsensusJobsAsync, switchMoveTaskModalVisible, } from 'actions/tasks-actions'; import { exportActions } from 'actions/export-actions'; import { importActions } from 'actions/import-actions'; +import { consensusActions } from 'actions/consensus-actions'; interface OwnProps { taskInstance: any; @@ -28,7 +28,6 @@ interface StateToProps { annotationFormats: any; inferenceIsActive: boolean; backupIsActive: boolean; - mergingIsActive: boolean; } interface DispatchToProps { @@ -37,7 +36,7 @@ interface DispatchToProps { openRunModelWindow: (taskInstance: any) => void; deleteTask: (taskInstance: any) => void; openMoveTaskToProjectWindow: (taskInstance: any) => void; - onMergeTaskConsensusJobs: (taskInstance: any) => void; + showConsensusModal: (taskInstance: any) => void; } function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { @@ -53,7 +52,6 @@ function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { annotationFormats, inferenceIsActive: tid in state.models.inferences, backupIsActive: state.export.tasks.backup.current[tid], - mergingIsActive: state.tasks.activities.mergingConsensus[tid], }; } @@ -78,8 +76,8 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { openMoveTaskToProjectWindow: (taskId: number): void => { dispatch(switchMoveTaskModalVisible(true, taskId)); }, - onMergeTaskConsensusJobs: (taskInstance: any): void => { - dispatch(mergeTaskConsensusJobsAsync(taskInstance)); + showConsensusModal: (taskInstance: any): void => { + dispatch(consensusActions.openConsensusModal(taskInstance)); }, }; } @@ -90,14 +88,13 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): annotationFormats: { loaders, dumpers }, inferenceIsActive, backupIsActive, - mergingIsActive, showExportModal, showImportModal, deleteTask, openRunModelWindow, openMoveTaskToProjectWindow, onViewAnalytics, - onMergeTaskConsensusJobs, + showConsensusModal, } = props; const onClickMenu = (params: MenuInfo): void | JSX.Element => { const [action] = params.keyPath; @@ -117,8 +114,8 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): showImportModal(taskInstance); } else if (action === Actions.VIEW_ANALYTICS) { onViewAnalytics(); - } else if (action === Actions.MERGE_TASK_CONSENSUS_JOBS) { - onMergeTaskConsensusJobs(taskInstance); + } else if (action === Actions.SHOW_TASK_CONSENSUS_CONFIGURATION) { + showConsensusModal(taskInstance); } }; @@ -134,7 +131,6 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): onClickMenu={onClickMenu} taskDimension={taskInstance.dimension} backupIsActive={backupIsActive} - mergingIsActive={mergingIsActive} consensusJobsPerSegment={taskInstance.consensusJobsPerSegment} /> ); diff --git a/cvat-ui/src/cvat-core-wrapper.ts b/cvat-ui/src/cvat-core-wrapper.ts index 3731013d305..581524e5866 100644 --- a/cvat-ui/src/cvat-core-wrapper.ts +++ b/cvat-ui/src/cvat-core-wrapper.ts @@ -20,6 +20,7 @@ import Project from 'cvat-core/src/project'; import QualityReport, { QualitySummary } from 'cvat-core/src/quality-report'; import QualityConflict, { AnnotationConflict, ConflictSeverity } from 'cvat-core/src/quality-conflict'; import QualitySettings from 'cvat-core/src/quality-settings'; +import ConsensusSettings from 'cvat-core/src/consensus-settings'; import { FramesMetaData, FrameData } from 'cvat-core/src/frames'; import { ServerError } from 'cvat-core/src/exceptions'; import { @@ -86,6 +87,7 @@ export { QualityReport, QualityConflict, QualitySettings, + ConsensusSettings, AnnotationConflict, ConflictSeverity, FramesMetaData, diff --git a/cvat-ui/src/reducers/consensus-reducer.ts b/cvat-ui/src/reducers/consensus-reducer.ts new file mode 100644 index 00000000000..6d6c7a47c57 --- /dev/null +++ b/cvat-ui/src/reducers/consensus-reducer.ts @@ -0,0 +1,91 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { ConsensusActions, ConsensusActionTypes } from 'actions/consensus-actions'; +import { ConsensusState } from '.'; + +const defaultState: ConsensusState = { + taskInstance: null, + fetching: true, + consensusSettings: null, + mergingConsensus: {}, +}; + +export default (state: ConsensusState = defaultState, action: ConsensusActions): ConsensusState => { + switch (action.type) { + case ConsensusActionTypes.OPEN_CONSENSUS_MODAL: { + const { instance } = action.payload; + + console.log('OPEN_CONSENSUS_MODAL'); + + return { + ...state, + taskInstance: instance, + }; + } + case ConsensusActionTypes.CLOSE_CONSENSUS_MODAL: { + return { + ...state, + taskInstance: null, + }; + } + case ConsensusActionTypes.SET_FETCHING: { + return { + ...state, + fetching: action.payload.fetching, + }; + } + + case ConsensusActionTypes.SET_CONSENSUS_SETTINGS: { + console.log(state.consensusSettings); + return { + ...state, + consensusSettings: action.payload.consensusSettings, + }; + } + + case ConsensusActionTypes.MERGE_CONSENSUS_JOBS: { + const { taskID } = action.payload; + const { mergingConsensus } = state; + + mergingConsensus[taskID] = true; + + return { + ...state, + mergingConsensus: { + ...mergingConsensus, + }, + }; + } + + case ConsensusActionTypes.MERGE_CONSENSUS_JOBS_SUCCESS: { + const { taskID } = action.payload; + const { mergingConsensus } = state; + + mergingConsensus[taskID] = false; + + return { + ...state, + mergingConsensus: { + ...mergingConsensus, + }, + }; + } + case ConsensusActionTypes.MERGE_CONSENSUS_JOBS_FAILED: { + const { taskID } = action.payload; + const { mergingConsensus } = state; + + delete mergingConsensus[taskID]; + + return { + ...state, + mergingConsensus: { + ...mergingConsensus, + }, + }; + } + default: + return state; + } +}; diff --git a/cvat-ui/src/reducers/index.ts b/cvat-ui/src/reducers/index.ts index b151e93004a..effb0acb8d0 100644 --- a/cvat-ui/src/reducers/index.ts +++ b/cvat-ui/src/reducers/index.ts @@ -109,9 +109,6 @@ export interface TasksState { deletes: { [tid: number]: boolean; // deleted (deleting if in dictionary) }; - mergingConsensus: { - [tid: number]: boolean; - }; }; } @@ -195,6 +192,15 @@ export interface ImportState { instanceType: 'project' | 'task' | 'job' | null; } +export interface ConsensusState { + fetching: boolean; + consensusSettings: any | null; + taskInstance: any | null; + mergingConsensus: { + [tid: number]: boolean; + }; +} + export interface FormatsState { annotationFormats: any; fetching: boolean; @@ -971,6 +977,7 @@ export interface CombinedState { review: ReviewState; export: ExportState; import: ImportState; + consensus: ConsensusState; cloudStorages: CloudStoragesState; organizations: OrganizationState; invitations: InvitationsState; diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index 4c4af1ee177..2ea6456b689 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -25,6 +25,7 @@ import { JobsActionTypes } from 'actions/jobs-actions'; import { WebhooksActionsTypes } from 'actions/webhooks-actions'; import { InvitationsActionTypes } from 'actions/invitations-actions'; import { ServerAPIActionTypes } from 'actions/server-actions'; +import { ConsensusActionTypes } from 'actions/consensus-actions'; import { NotificationsState } from '.'; @@ -681,7 +682,7 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } - case TasksActionTypes.MERGE_TASK_CONSENSUS_FAILED: { + case ConsensusActionTypes.MERGE_CONSENSUS_JOBS_FAILED: { const { taskID } = action.payload; if (action.payload.error.code === 400) { action.payload.error.message = "Consensus Jobs aren't annotated."; @@ -702,7 +703,7 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } - case TasksActionTypes.MERGE_TASK_CONSENSUS_SUCCESS: { + case ConsensusActionTypes.MERGE_CONSENSUS_JOBS_SUCCESS: { const { taskID } = action.payload; return { ...state, diff --git a/cvat-ui/src/reducers/root-reducer.ts b/cvat-ui/src/reducers/root-reducer.ts index a766325c789..01a2e370ade 100644 --- a/cvat-ui/src/reducers/root-reducer.ts +++ b/cvat-ui/src/reducers/root-reducer.ts @@ -20,6 +20,7 @@ import userAgreementsReducer from './useragreements-reducer'; import reviewReducer from './review-reducer'; import exportReducer from './export-reducer'; import importReducer from './import-reducer'; +import consensusReducer from './consensus-reducer'; import cloudStoragesReducer from './cloud-storages-reducer'; import organizationsReducer from './organizations-reducer'; import webhooksReducer from './webhooks-reducer'; @@ -44,6 +45,7 @@ export default function createRootReducer(): Reducer { review: reviewReducer, export: exportReducer, import: importReducer, + consensus: consensusReducer, cloudStorages: cloudStoragesReducer, organizations: organizationsReducer, webhooks: webhooksReducer, diff --git a/cvat-ui/src/reducers/tasks-reducer.ts b/cvat-ui/src/reducers/tasks-reducer.ts index 827fd87511f..ce2e88258c0 100644 --- a/cvat-ui/src/reducers/tasks-reducer.ts +++ b/cvat-ui/src/reducers/tasks-reducer.ts @@ -31,7 +31,6 @@ const defaultState: TasksState = { }, activities: { deletes: {}, - mergingConsensus: {}, }, }; @@ -134,54 +133,6 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState }, }; } - case TasksActionTypes.MERGE_TASK_CONSENSUS: { - const { taskID } = action.payload; - const { mergingConsensus } = state.activities; - - mergingConsensus[taskID] = true; - - return { - ...state, - activities: { - ...state.activities, - mergingConsensus: { - ...mergingConsensus, - }, - }, - }; - } - case TasksActionTypes.MERGE_TASK_CONSENSUS_SUCCESS: { - const { taskID } = action.payload; - const { mergingConsensus } = state.activities; - - mergingConsensus[taskID] = false; - - return { - ...state, - activities: { - ...state.activities, - mergingConsensus: { - ...mergingConsensus, - }, - }, - }; - } - case TasksActionTypes.MERGE_TASK_CONSENSUS_FAILED: { - const { taskID } = action.payload; - const { mergingConsensus } = state.activities; - - delete mergingConsensus[taskID]; - - return { - ...state, - activities: { - ...state.activities, - mergingConsensus: { - ...mergingConsensus, - }, - }, - }; - } case TasksActionTypes.SWITCH_MOVE_TASK_MODAL_VISIBLE: { return { ...state, diff --git a/cvat/apps/consensus/apps.py b/cvat/apps/consensus/apps.py index 446107f5800..62d8500483f 100644 --- a/cvat/apps/consensus/apps.py +++ b/cvat/apps/consensus/apps.py @@ -9,5 +9,10 @@ class ConsensusConfig(AppConfig): name = "cvat.apps.consensus" 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/consensus/migrations/0001_initial.py b/cvat/apps/consensus/migrations/0001_initial.py new file mode 100644 index 00000000000..f2f24844608 --- /dev/null +++ b/cvat/apps/consensus/migrations/0001_initial.py @@ -0,0 +1,69 @@ +# Generated by Django 4.2.11 on 2024-06-27 11:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("engine", "0079_job_parent_job_id_task_consensus_jobs_per_segment"), + ] + + operations = [ + migrations.CreateModel( + name="ConsensusSettings", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("agreement_score_threshold", models.FloatField()), + ("quorum", models.IntegerField()), + ("iou_threshold", models.FloatField()), + ( + "task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="consensus_settings", + to="engine.task", + ), + ), + ], + ), + migrations.CreateModel( + name="ConsensusReport", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_date", models.DateTimeField(auto_now_add=True)), + ("data", models.JSONField()), + ( + "task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="consensus_reports", + to="engine.task", + ), + ), + ], + ), + ] diff --git a/cvat/apps/consensus/models.py b/cvat/apps/consensus/models.py index f8e14701170..c9eed370c22 100644 --- a/cvat/apps/consensus/models.py +++ b/cvat/apps/consensus/models.py @@ -1,11 +1,29 @@ from django.db import models +from typing import Any +from copy import deepcopy from cvat.apps.engine.models import Job, ShapeType, Task -from django.db import models +from django.forms.models import model_to_dict + + +class ConsensusSettings(models.Model): + task = models.ForeignKey( + Task, on_delete=models.CASCADE, related_name="consensus_settings", null=True, blank=True + ) + agreement_score_threshold = models.FloatField(default=0) + quorum = models.IntegerField(default=0) + iou_threshold = models.FloatField(default=0) + + def to_dict(self): + return model_to_dict(self) + + @property + def organization_id(self): + return getattr(self.task.organization, "id", None) -class MergeReport(models.Model): +class ConsensusReport(models.Model): task = models.ForeignKey( - Task, on_delete=models.CASCADE, related_name="merge_reports", null=True, blank=True + Task, on_delete=models.CASCADE, related_name="consensus_reports", null=True, blank=True ) created_date = models.DateTimeField(auto_now_add=True) - data = models.JSONField() \ No newline at end of file + data = models.JSONField() diff --git a/cvat/apps/consensus/permissions.py b/cvat/apps/consensus/permissions.py new file mode 100644 index 00000000000..a8e97c480d9 --- /dev/null +++ b/cvat/apps/consensus/permissions.py @@ -0,0 +1,112 @@ +# Copyright (C) 2022 Intel Corporation +# Copyright (C) 2022-2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from typing import Optional, Union, cast + +from django.conf import settings +from rest_framework.exceptions import ValidationError + +from cvat.apps.engine.models import Task +from cvat.apps.engine.permissions import TaskPermission +from cvat.apps.iam.permissions import OpenPolicyAgentPermission, StrEnum, get_iam_context + +from .models import ConsensusSettings + + +class ConsensusSettingPermission(OpenPolicyAgentPermission): + obj: Optional[ConsensusSettings] + + class Scopes(StrEnum): + LIST = "list" + VIEW = "view" + UPDATE = "update" + + @classmethod + def create(cls, request, view, obj, iam_context): + Scopes = __class__.Scopes + + permissions = [] + if view.basename == "consensus_settings": + for scope in cls.get_scopes(request, view, obj): + if scope in [Scopes.VIEW, Scopes.UPDATE]: + obj = cast(ConsensusSettings, obj) + + if scope == Scopes.VIEW: + task_scope = TaskPermission.Scopes.VIEW + elif scope == Scopes.UPDATE: + task_scope = TaskPermission.Scopes.UPDATE_DESC + else: + assert False + + # Access rights are the same as in the owning task + # This component doesn't define its own rules in this case + permissions.append( + TaskPermission.create_base_perm( + request, view, iam_context=iam_context, scope=task_scope, obj=obj.task + ) + ) + elif scope == cls.Scopes.LIST: + if task_id := request.query_params.get("task_id", None): + permissions.append( + TaskPermission.create_scope_view( + request, + int(task_id), + iam_context=iam_context, + ) + ) + + permissions.append(cls.create_scope_list(request, iam_context)) + else: + permissions.append(cls.create_base_perm(request, view, scope, iam_context, obj)) + + return permissions + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.url = settings.IAM_OPA_DATA_URL + "/consensus_settings/allow" + + @staticmethod + def get_scopes(request, view, obj): + Scopes = __class__.Scopes + return [ + { + "list": Scopes.LIST, + "retrieve": Scopes.VIEW, + "partial_update": Scopes.UPDATE, + }.get(view.action, None) + ] + + def get_resource(self): + data = None + + if self.obj: + task = self.obj.task + if task.project: + organization = task.project.organization + else: + organization = task.organization + + data = { + "id": self.obj.id, + "organization": {"id": getattr(organization, "id", None)}, + "task": ( + { + "owner": {"id": getattr(task.owner, "id", None)}, + "assignee": {"id": getattr(task.assignee, "id", None)}, + } + if task + else None + ), + "project": ( + { + "owner": {"id": getattr(task.project.owner, "id", None)}, + "assignee": {"id": getattr(task.project.assignee, "id", None)}, + } + if task.project + else None + ), + } + + return data diff --git a/cvat/apps/consensus/rules/consensus_settings.rego b/cvat/apps/consensus/rules/consensus_settings.rego new file mode 100644 index 00000000000..fd67061c6e1 --- /dev/null +++ b/cvat/apps/consensus/rules/consensus_settings.rego @@ -0,0 +1,104 @@ +package consensus_settings + +import rego.v1 + +import data.utils +import data.organizations + +# input: { +# "scope": <"view"> or null, +# "auth": { +# "user": { +# "id": <num>, +# "privilege": <"admin"|"business"|"user"|"worker"> or null +# }, +# "organization": { +# "id": <num>, +# "owner": { +# "id": <num> +# }, +# "user": { +# "role": <"owner"|"maintainer"|"supervisor"|"worker"> or null +# } +# } or null, +# }, +# "resource": { +# "id": <num>, +# "owner": { "id": <num> }, +# "organization": { "id": <num> } or null, +# "task": { +# "id": <num>, +# "owner": { "id": <num> }, +# "assignee": { "id": <num> }, +# "organization": { "id": <num> } or null, +# } or null, +# "project": { +# "id": <num>, +# "owner": { "id": <num> }, +# "assignee": { "id": <num> }, +# "organization": { "id": <num> } 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/consensus/serializers.py b/cvat/apps/consensus/serializers.py new file mode 100644 index 00000000000..262fe2766a2 --- /dev/null +++ b/cvat/apps/consensus/serializers.py @@ -0,0 +1,46 @@ +from rest_framework import serializers +from cvat.apps.consensus.models import ConsensusSettings +from cvat.apps.engine.models import Task +from django.db import IntegrityError, models, transaction +import textwrap + +class ConsensusSettingsSerializer(serializers.ModelSerializer): + class Meta: + model = ConsensusSettings + fields = ( + "id", + "task_id", + "iou_threshold", + "agreement_score_threshold", + "quorum", + ) + read_only_fields = ( + "id", + "task_id", + ) + + extra_kwargs = {k: {"required": False} for k in fields} + + for field_name, help_text in { + "iou_threshold": "Used for distinction between matched / unmatched shapes", + "agreement_score_threshold": """ + Confidence threshold for output annotations + """, + "quorum": """ + Minimum count for a label and attribute voting results to be counted + """, + }.items(): + extra_kwargs.setdefault(field_name, {}).setdefault( + "help_text", textwrap.dedent(help_text.lstrip("\n")) + ) + + def validate(self, attrs): + for k, v in attrs.items(): + if k.endswith("_threshold"): + if not 0 <= v <= 1: + raise serializers.ValidationError(f"{k} must be in the range [0; 1]") + elif k == "quorum": + if not 0 <= v <= 10: + raise serializers.ValidationError(f"{k} must be in the range [0; 10]") + + return super().validate(attrs) diff --git a/cvat/apps/consensus/signals.py b/cvat/apps/consensus/signals.py new file mode 100644 index 00000000000..bba9b5f3a7d --- /dev/null +++ b/cvat/apps/consensus/signals.py @@ -0,0 +1,28 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from django.db import transaction +from django.db.models.signals import post_save +from django.dispatch import receiver + +from cvat.apps.engine.models import Annotation, Job, Project, Task +# from cvat.apps.quality_control import quality_reports as qc +from cvat.apps.consensus.models import ConsensusSettings + + +@receiver(post_save, sender=Task, dispatch_uid=__name__ + ".save_task-initialize_consensus_settings") +@receiver(post_save, sender=Job, dispatch_uid=__name__ + ".save_job-initialize_consensus_settings") +def __save_task__initialize_consensus_settings(instance, created, **kwargs): + # Initializes default quality settings for the task + # this is done in a signal to decouple this component from the engine app + + if created: + if isinstance(instance, Task): + task = instance + elif isinstance(instance, Job): + task = instance.segment.task + else: + assert False + + ConsensusSettings.objects.get_or_create(task=task) diff --git a/cvat/apps/consensus/urls.py b/cvat/apps/consensus/urls.py new file mode 100644 index 00000000000..0654ac2291e --- /dev/null +++ b/cvat/apps/consensus/urls.py @@ -0,0 +1,17 @@ +# Copyright (C) 2023 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from django.urls import include, path +from rest_framework import routers + +from cvat.apps.consensus import views + +router = routers.DefaultRouter(trailing_slash=False) +# router.register("reports", views.ConsensusReportViewSet, basename="consensus_reports") +router.register("settings", views.ConsensusSettingsViewSet, basename="consensus_settings") + +urlpatterns = [ + # entry point for API + path("consensus/", include(router.urls)), +] diff --git a/cvat/apps/consensus/views.py b/cvat/apps/consensus/views.py index 39877857a60..afd0ce92b6f 100644 --- a/cvat/apps/consensus/views.py +++ b/cvat/apps/consensus/views.py @@ -1,6 +1,55 @@ -from django.shortcuts import render +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT -# Create your views here. +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.quality_control import quality_reports as qc +from cvat.apps.quality_control.models import ( + AnnotationConflict, + QualityReport, + QualityReportTarget, + QualitySettings, +) +from cvat.apps.quality_control.permissions import ( + AnnotationConflictPermission, + QualityReportPermission, + QualitySettingPermission, +) +from cvat.apps.quality_control.serializers import ( + AnnotationConflictSerializer, + QualityReportCreateSerializer, + QualityReportSerializer, + QualitySettingsSerializer, +) + +from cvat.apps.consensus.serializers import ( + ConsensusSettingsSerializer +) +from cvat.apps.consensus.models import ( + ConsensusSettings +) +from cvat.apps.consensus.permissions import ( + ConsensusSettingPermission +) """ engine> views.py> TaskViewSet @@ -14,5 +63,72 @@ or somewhat like storing report attributes. /aggregate/ => list of merge reports +/aggregate/settings/ """ + + +@extend_schema(tags=["consensus"]) +@extend_schema_view( + list=extend_schema( + summary="List quality settings instances", + responses={ + "200": ConsensusSettingsSerializer(many=True), + }, + ), + retrieve=extend_schema( + summary="Get quality settings instance details", + parameters=[ + OpenApiParameter( + "id", + type=OpenApiTypes.INT, + location="path", + description="An id of a quality settings instance", + ) + ], + responses={ + "200": ConsensusSettingsSerializer, + }, + ), + partial_update=extend_schema( + summary="Update a quality settings instance", + parameters=[ + OpenApiParameter( + "id", + type=OpenApiTypes.INT, + location="path", + description="An id of a quality settings instance", + ) + ], + request=ConsensusSettingsSerializer(partial=True), + responses={ + "200": ConsensusSettingsSerializer, + }, + ), +) +class ConsensusSettingsViewSet( + viewsets.GenericViewSet, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + PartialUpdateModelMixin, +): + queryset = ConsensusSettings.objects.select_related("task", "task__organization").all() + + iam_organization_field = "task__organization" + + search_fields = [] + filter_fields = ["id", "task_id"] + simple_filters = ["task_id"] + ordering_fields = ["id"] + ordering = "id" + + serializer_class = ConsensusSettingsSerializer + + def get_queryset(self): + queryset = super().get_queryset() + + if self.action == "list": + permissions = ConsensusSettingPermission.create_scope_list(self.request) + queryset = permissions.filter(queryset) + + return queryset diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 689ba4a04c7..69ac9ae3d99 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -117,6 +117,7 @@ def generate_secret_key(): 'cvat.apps.events', 'cvat.apps.quality_control', 'cvat.apps.analytics_report', + 'cvat.apps.consensus', ] SITE_ID = 1 diff --git a/cvat/urls.py b/cvat/urls.py index 144ed619f76..0eb10869f84 100644 --- a/cvat/urls.py +++ b/cvat/urls.py @@ -51,3 +51,6 @@ if apps.is_installed('cvat.apps.analytics_report'): urlpatterns.append(path('api/', include('cvat.apps.analytics_report.urls'))) + +if apps.is_installed('cvat.apps.consensus'): + urlpatterns.append(path('api/', include('cvat.apps.consensus.urls'))) From 8d5520aef3bc137cde29e55cdf84e1ac8fbeb2e3 Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Tue, 2 Jul 2024 16:12:33 +0530 Subject: [PATCH 050/301] removed `tobeSkipped` from advanced-configuration-form --- .../create-task-page/advanced-configuration-form.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 e538beef6df..2749147b3cf 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 @@ -92,7 +92,7 @@ function validateURL(_: RuleObject, value: string): Promise<void> { return Promise.resolve(); } -const isInteger = ({ min, max, toBeSkipped }: { min?: number; max?: number; toBeSkipped?: number }) => ( +export const isInteger = ({ min, max }: { min?: number; max?: number; }) => ( _: RuleObject, value?: number | string, ): Promise<void> => { @@ -113,10 +113,6 @@ const isInteger = ({ min, max, toBeSkipped }: { min?: number; max?: number; toBe return Promise.reject(new Error(`Value must be less than ${max}`)); } - if (typeof toBeSkipped !== 'undefined' && intValue === toBeSkipped) { - return Promise.reject(new Error(`Value shouldn't be equal to ${toBeSkipped}`)); - } - return Promise.resolve(); }; From 4bb2f8eec29927783e1a371f28c55273e965f2c7 Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Tue, 2 Jul 2024 16:13:22 +0530 Subject: [PATCH 051/301] removed `strictInt` from `consensus-configuration-form.tsx` since `agreement-score-threshold` removed thus it's not needed anymore --- .../consensus-configuration-form.tsx | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx index e0ae420302e..6b21ddcc556 100644 --- a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx @@ -25,8 +25,7 @@ const isNumber = ({ min, max, toBeSkipped, - strictInt, -}: { min?: number; max?: number; toBeSkipped?: number, strictInt?: boolean }) => ( +}: { min?: number; max?: number; toBeSkipped?: number }) => ( _: RuleObject, value?: number | string, ): Promise<void> => { @@ -35,12 +34,8 @@ const isNumber = ({ } const intValue = +value; - if (Number.isFinite(intValue)) { - if (strictInt && !Number.isInteger(intValue)) { - return Promise.reject(new Error('Value must be a positive integer')); - } - } else { - return Promise.reject(new Error('Value must be a finite number')); + if (!Number.isFinite(intValue) && !Number.isInteger(intValue)) { + return Promise.reject(new Error('Value must be a positive integer')); } if (typeof min !== 'undefined' && intValue < min) { @@ -98,14 +93,15 @@ class ConsensusConfigurationForm extends React.PureComponent<Props> { <Form.Item label='Consensus Job Per Segment' name='consensusJobsPerSegment' - rules={[{ - validator: isNumber({ - min: 0, - max: 10, - toBeSkipped: 1, - strictInt: true, - }), - }]} + rules={[ + { + validator: isNumber({ + min: 0, + max: 10, + toBeSkipped: 1, + }), + }, + ]} > <Input size='large' type='number' min={0} step={1} /> </Form.Item> From 02af5f13f92b30b84c50a23189335ae71852f0d1 Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Tue, 2 Jul 2024 16:22:05 +0530 Subject: [PATCH 052/301] added `CONSENSUS` as a JobType --- cvat/apps/dataset_manager/task.py | 2 +- cvat/apps/engine/models.py | 1 + cvat/apps/engine/task.py | 4 +--- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index ba792dcfc8c..074fd7435e6 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -731,7 +731,7 @@ def __init__(self, pk): # Postgres doesn't guarantee an order by default without explicit order_by # Only select normal jobs, not ground truth or consensus jobs self.db_jobs = models.Job.objects.select_related("segment").filter( - segment__task_id=pk, type=models.JobType.ANNOTATION.value, parent_job_id=None, + segment__task_id=pk, type=models.JobType.ANNOTATION.value, ).order_by('id') self.ir_data = AnnotationIR(self.db_task.dimension) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 1ae3a793aa0..322e3106a03 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -162,6 +162,7 @@ def __str__(self): class JobType(str, Enum): ANNOTATION = 'annotation' GROUND_TRUTH = 'ground_truth' + CONSENSUS = 'consensus' @classmethod def choices(cls): diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 4314bff587c..ef96edad60c 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -174,7 +174,7 @@ def _save_task_to_db(db_task: models.Task, *, job_file_mapping: Optional[JobFile # consensus jobs use the same `db_segment` as the normal job, thus data not duplicated in backups, exports for _ in range(db_task.consensus_jobs_per_segment): - consensus_db_job = models.Job(segment=db_segment, parent_job_id=db_job.id) + consensus_db_job = models.Job(segment=db_segment, parent_job_id=db_job.id, type=models.JobType.CONSENSUS) consensus_db_job.save() consensus_db_job.make_dirs() @@ -367,7 +367,6 @@ def _validate_scheme(url): if parsed_url.scheme not in ALLOWED_SCHEMES: raise ValueError('Unsupported URL scheme: {}. Only http and https are supported'.format(parsed_url.scheme)) - def _download_data(urls, upload_dir): job = rq.get_current_job() local_files = {} @@ -519,7 +518,6 @@ def _create_thread( 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 From 387afc0659d43ccd3a74790204e1f32c72b48ffb Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Tue, 2 Jul 2024 16:25:35 +0530 Subject: [PATCH 053/301] added `allow_null=True` to field `consensus_jobs_per_segment` in Task Serializers --- cvat/apps/engine/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 5ad8a610226..5669af62336 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1097,7 +1097,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='*') - consensus_jobs_per_segment = serializers.ReadOnlyField(required=False) + consensus_jobs_per_segment = serializers.ReadOnlyField(required=False, allow_null=True) class Meta: model = models.Task @@ -1122,7 +1122,7 @@ class TaskWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): project_id = serializers.IntegerField(required=False, allow_null=True) target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) - consensus_jobs_per_segment = serializers.IntegerField(required=False) + consensus_jobs_per_segment = serializers.IntegerField(required=False, allow_null=True) class Meta: model = models.Task From 57bb35114eb430c28badffcdc36673b50156c4bf Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Tue, 2 Jul 2024 16:47:50 +0530 Subject: [PATCH 054/301] additional changes to incorporate `allow_null=True` in the serializer --- cvat/apps/engine/serializers.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 5669af62336..3d862b237f0 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1311,10 +1311,7 @@ def validate(self, attrs): consensus_jobs_per_segment = attrs.get('consensus_jobs_per_segment', self.instance.consensus_jobs_per_segment if self.instance else None) - if consensus_jobs_per_segment is None: - raise serializers.ValidationError("Consensus job per segment can't be None") - - if consensus_jobs_per_segment == 1 or consensus_jobs_per_segment < 0: + if consensus_jobs_per_segment and (consensus_jobs_per_segment == 1 or consensus_jobs_per_segment < 0): raise serializers.ValidationError("Consensus job per segment should be greater than or equal to 0 and not 1") return attrs From fb727426f9669f776b21ec56a5b0bd709acdda74 Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Tue, 2 Jul 2024 16:48:17 +0530 Subject: [PATCH 055/301] added consensus in enum `JobType` --- cvat-core/src/enums.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cvat-core/src/enums.ts b/cvat-core/src/enums.ts index 5543d4712af..9a575f10a7c 100644 --- a/cvat-core/src/enums.ts +++ b/cvat-core/src/enums.ts @@ -37,6 +37,7 @@ export enum JobState { export enum JobType { ANNOTATION = 'annotation', GROUND_TRUTH = 'ground_truth', + consensus = 'consensus', } export enum DimensionType { From 6f0b3e8498280f98f7a85f4b52bc1d15ccd64e91 Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Tue, 2 Jul 2024 16:50:47 +0530 Subject: [PATCH 056/301] used the `JobType` and `job.type` parameter to change job name --- cvat-core/src/enums.ts | 2 +- cvat-ui/src/components/job-item/job-item.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat-core/src/enums.ts b/cvat-core/src/enums.ts index 9a575f10a7c..150a9f2f04d 100644 --- a/cvat-core/src/enums.ts +++ b/cvat-core/src/enums.ts @@ -37,7 +37,7 @@ export enum JobState { export enum JobType { ANNOTATION = 'annotation', GROUND_TRUTH = 'ground_truth', - consensus = 'consensus', + CONSENSUS = 'consensus', } export enum DimensionType { diff --git a/cvat-ui/src/components/job-item/job-item.tsx b/cvat-ui/src/components/job-item/job-item.tsx index d7cb58fca1d..c33293bafd9 100644 --- a/cvat-ui/src/components/job-item/job-item.tsx +++ b/cvat-ui/src/components/job-item/job-item.tsx @@ -116,7 +116,7 @@ function JobItem(props: Props): JSX.Element { const frameCountPercentRepresentation = frameCountPercent === '0' ? '<1' : frameCountPercent; let jobName = `Job #${job.id}`; if (task.consensusJobsPerSegment && job.type !== JobType.GROUND_TRUTH) { - jobName = job.parentJobId === null ? `Normal Job #${job.id}` : `Consensus Job #${job.id}`; + jobName = job.type === JobType.CONSENSUS ? `Consensus Job #${job.id}` : `Normal Job #${job.id}`; } let consensusJob: Job[] = []; From a17a11024f1237751125de43f88c5da818926f2c Mon Sep 17 00:00:00 2001 From: vidit <vidit.agarwal.eee20@itbhu.ac.in> Date: Tue, 2 Jul 2024 16:56:54 +0530 Subject: [PATCH 057/301] added tag of `Consensus Based Annotation` to task name instead of changing the task name --- cvat-ui/src/components/task-page/details.tsx | 44 ++++++++++++------- .../src/components/tasks-page/task-item.tsx | 8 ++++ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/cvat-ui/src/components/task-page/details.tsx b/cvat-ui/src/components/task-page/details.tsx index 1a0d227c5b9..7e5a433c8e3 100644 --- a/cvat-ui/src/components/task-page/details.tsx +++ b/cvat-ui/src/components/task-page/details.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { Row, Col } from 'antd/lib/grid'; +import Tag from 'antd/lib/tag'; import Text from 'antd/lib/typography/Text'; import Title from 'antd/lib/typography/Title'; import moment from 'moment'; @@ -96,25 +97,36 @@ class DetailsComponent extends React.PureComponent<Props, State> { private renderTaskName(): JSX.Element { const { name, consensusJobsPerSegment } = this.state; const { task: taskInstance, onUpdateTask } = this.props; - const taskName = name + (consensusJobsPerSegment > 0 ? ' (Consensus Based Annotation)' : ''); + const taskName = name; return ( - <Title - level={4} - editable={{ - onChange: (value: string): void => { - this.setState({ - name: value, - }); + <Row> + <Col> + <Title + level={4} + editable={{ + onChange: (value: string): void => { + this.setState({ + name: value, + }); - taskInstance.name = value; - onUpdateTask(taskInstance); - }, - }} - className='cvat-text-color cvat-task-name' - > - { taskName } - + taskInstance.name = value; + onUpdateTask(taskInstance); + }, + }} + className='cvat-text-color cvat-task-name' + > + {taskName} + + + { + consensusJobsPerSegment > 0 && ( + + Consensus Based Annotation + + ) + } +
); } diff --git a/cvat-ui/src/components/tasks-page/task-item.tsx b/cvat-ui/src/components/tasks-page/task-item.tsx index 8cfbbd377b2..d071ec4bac7 100644 --- a/cvat-ui/src/components/tasks-page/task-item.tsx +++ b/cvat-ui/src/components/tasks-page/task-item.tsx @@ -8,6 +8,7 @@ import { RouteComponentProps } from 'react-router'; import { withRouter } from 'react-router-dom'; import Text from 'antd/lib/typography/Text'; import { Row, Col } from 'antd/lib/grid'; +import Tag from 'antd/lib/tag'; import Button from 'antd/lib/button'; import { LoadingOutlined, MoreOutlined } from '@ant-design/icons'; import Dropdown from 'antd/lib/dropdown'; @@ -121,6 +122,13 @@ class TaskItemComponent extends React.PureComponent{`#${id}: `} {taskInstance.name} + { + taskInstance.consensusJobsPerSegment > 0 && ( + + Consensus Based Annotation + + ) + }
From 93dca8bb263b675abf7a2d92f11cc00a5a3dba00 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 3 Jul 2024 10:52:15 +0530 Subject: [PATCH 058/301] remove the "Consensus Based Annotation" tag from tasks list page --- cvat-ui/src/components/tasks-page/task-item.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/cvat-ui/src/components/tasks-page/task-item.tsx b/cvat-ui/src/components/tasks-page/task-item.tsx index d071ec4bac7..8cfbbd377b2 100644 --- a/cvat-ui/src/components/tasks-page/task-item.tsx +++ b/cvat-ui/src/components/tasks-page/task-item.tsx @@ -8,7 +8,6 @@ import { RouteComponentProps } from 'react-router'; import { withRouter } from 'react-router-dom'; import Text from 'antd/lib/typography/Text'; import { Row, Col } from 'antd/lib/grid'; -import Tag from 'antd/lib/tag'; import Button from 'antd/lib/button'; import { LoadingOutlined, MoreOutlined } from '@ant-design/icons'; import Dropdown from 'antd/lib/dropdown'; @@ -122,13 +121,6 @@ class TaskItemComponent extends React.PureComponent{`#${id}: `} {taskInstance.name} - { - taskInstance.consensusJobsPerSegment > 0 && ( - - Consensus Based Annotation - - ) - }
From a38c7764f57b3ff277c7d506db431988b63effbb Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 3 Jul 2024 10:52:54 +0530 Subject: [PATCH 059/301] change colour of tag `Consensus Based Annotation` when viewing individual task --- cvat-ui/src/components/task-page/details.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat-ui/src/components/task-page/details.tsx b/cvat-ui/src/components/task-page/details.tsx index 7e5a433c8e3..2034b422782 100644 --- a/cvat-ui/src/components/task-page/details.tsx +++ b/cvat-ui/src/components/task-page/details.tsx @@ -122,7 +122,7 @@ class DetailsComponent extends React.PureComponent { { consensusJobsPerSegment > 0 && ( - Consensus Based Annotation + Consensus Based Annotation ) } From d38635c00960b6c0986db88da10f6245eeb1128e Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 3 Jul 2024 11:21:03 +0530 Subject: [PATCH 060/301] removed unwanted `console.log` --- cvat-core/src/server-proxy.ts | 2 -- cvat-ui/src/reducers/consensus-reducer.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index eee69c2addc..24647fdd811 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -2419,8 +2419,6 @@ async function updateConsensusSettings( const params = enableOrganization(); const { backendAPI } = config; - console.log(settingsData); - try { const response = await Axios.patch(`${backendAPI}/consensus/settings/${settingsID}`, settingsData, { params, diff --git a/cvat-ui/src/reducers/consensus-reducer.ts b/cvat-ui/src/reducers/consensus-reducer.ts index 6d6c7a47c57..817d8a85ad5 100644 --- a/cvat-ui/src/reducers/consensus-reducer.ts +++ b/cvat-ui/src/reducers/consensus-reducer.ts @@ -38,7 +38,6 @@ export default (state: ConsensusState = defaultState, action: ConsensusActions): } case ConsensusActionTypes.SET_CONSENSUS_SETTINGS: { - console.log(state.consensusSettings); return { ...state, consensusSettings: action.payload.consensusSettings, From 384787de2620c798a84223b08885fe18f3ff805f Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 3 Jul 2024 11:46:16 +0530 Subject: [PATCH 061/301] `Save` and `Reset Settings` Button are centred in the consensus configuration form --- .../consensus/consensus-settings-form.tsx | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/cvat-ui/src/components/consensus/consensus-settings-form.tsx b/cvat-ui/src/components/consensus/consensus-settings-form.tsx index 63d225692f8..52b2626b39b 100644 --- a/cvat-ui/src/components/consensus/consensus-settings-form.tsx +++ b/cvat-ui/src/components/consensus/consensus-settings-form.tsx @@ -104,7 +104,7 @@ export default function ConsensusSettingsForm(props: Props): JSX.Element | null @@ -113,43 +113,43 @@ export default function ConsensusSettingsForm(props: Props): JSX.Element | null +
+ - + - - - - - - - - + + + + + + ); From a67b0e7c8e1531d696df999f3256ff25493e69df Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 3 Jul 2024 11:47:07 +0530 Subject: [PATCH 062/301] Colour of `Merge Consensus Task` button is now more coherrent with rest of UI colours --- cvat-ui/src/components/consensus/styles.scss | 37 +------------------- 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/cvat-ui/src/components/consensus/styles.scss b/cvat-ui/src/components/consensus/styles.scss index 301579532c1..e6360614504 100644 --- a/cvat-ui/src/components/consensus/styles.scss +++ b/cvat-ui/src/components/consensus/styles.scss @@ -6,7 +6,7 @@ align-items: center; .ant-btn-default { - background-color: #0059ff; + background-color: #1890ff; border: none; } } @@ -22,38 +22,3 @@ } } -.consensus-settings-form { - .ant-form-item { - display: flex; - justify-content: space-between; - - .ant-col { - display: flex; - justify-content: center; - - .ant-btn { - padding: 10px 0; - - &.reset-button { - background-color: #f5f5f5; - color: #000; - border: 1px solid #d9d9d9; - - &:hover { - background-color: #e6e6e6; - } - } - - &.save-button { - background-color: #1890ff; - color: #fff; - border: none; - - &:hover { - background-color: #40a9ff; - } - } - } - } - } -} \ No newline at end of file From 3b4ec80c64daf7cfb16a356378f5e0714c5c6581 Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 4 Jul 2024 15:25:47 +0530 Subject: [PATCH 063/301] added typed annotation in functions for `merge_consensus_jobs.py` --- cvat/apps/consensus/merge_consensus_jobs.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index 746ae915835..f423f734406 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -1,10 +1,14 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + from typing import List, Dict import datumaro as dm import django_rq from django.conf import settings from datumaro.components.operations import IntersectMerge from cvat.apps.quality_control.quality_reports import JobDataProvider -from cvat.apps.engine.models import Job, JobType +from cvat.apps.engine.models import Task, Job, JobType from cvat.apps.dataset_manager.bindings import import_dm_annotations from rest_framework import status from rest_framework.response import Response @@ -14,7 +18,8 @@ from django.utils import timezone from django.db import transaction -def get_consensus_jobs(task_id: int): + +def get_consensus_jobs(task_id: int) -> Dict[int, List[int]]: jobs = {} # parent_job_id -> [consensus_job_id] for job in Job.objects.select_related("segment").filter(segment__task_id=task_id, type=JobType.ANNOTATION.value).order_by('id'): if job.parent_job_id is not None: @@ -23,12 +28,12 @@ def get_consensus_jobs(task_id: int): jobs[job.parent_job_id].append(job.id) return jobs -def get_annotations(job_id: int): +def get_annotations(job_id: int) -> dm.Dataset: return JobDataProvider(job_id).dm_dataset @transaction.atomic -def _merge_consensus_jobs(task_id: int): +def _merge_consensus_jobs(task_id: int) -> None: jobs = get_consensus_jobs(task_id) merger = IntersectMerge() @@ -61,11 +66,11 @@ def _merge_consensus_jobs(task_id: int): return 201 -def merge_task(task, request): +def merge_task(task: Task, request) -> Response: queue_name=settings.CVAT_QUEUES.CONSENSUS.value queue = django_rq.get_queue(queue_name) # so a user doesn't create requests to merge same task multiple times - rq_id = rq_id = request.data.get('rq_id', f"merge_consensus:task.id{task.id}-by-{request.user}") + rq_id = request.data.get('rq_id', f"merge_consensus:task.id{task.id}-by-{request.user}") rq_job = queue.fetch_job(rq_id) user_id = request.user.id last_instance_update_time = timezone.localtime(task.updated_date) From a67a83ae070179bf373a9b357c25dbf87d92f517 Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 4 Jul 2024 15:26:48 +0530 Subject: [PATCH 064/301] made used the JobType parameter to make the mapping of parent_job to consensus_job --- cvat/apps/consensus/merge_consensus_jobs.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index f423f734406..f85621cc75e 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -21,11 +21,11 @@ def get_consensus_jobs(task_id: int) -> Dict[int, List[int]]: jobs = {} # parent_job_id -> [consensus_job_id] - for job in Job.objects.select_related("segment").filter(segment__task_id=task_id, type=JobType.ANNOTATION.value).order_by('id'): - if job.parent_job_id is not None: - if job.parent_job_id not in jobs: - jobs[job.parent_job_id] = [] - jobs[job.parent_job_id].append(job.id) + + for job in Job.objects.select_related("segment").filter(segment__task_id=task_id, type=JobType.CONSENSUS.value): + assert job.parent_job_id + jobs.setdefault(job.parent_job_id, []).append(job.id) + return jobs def get_annotations(job_id: int) -> dm.Dataset: From 5d71b416b99956bc237b1343a54a32a52d3d9777 Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 4 Jul 2024 15:27:56 +0530 Subject: [PATCH 065/301] converted documentation type comment block to normal comment block --- cvat/apps/consensus/merge_consensus_jobs.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index f85621cc75e..32ba97a305c 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -49,12 +49,10 @@ def _merge_consensus_jobs(task_id: int) -> None: # delete the existing annotations in the job patch_job_data(parent_job_id, None, PatchAction.DELETE) - """ - if we don't delete exising annotations, the imported annotations - will be appended to the existing annotations, and thus updated annotation - would have both exisiting + imported annotations, but we only want the - imported annotations - """ + # if we don't delete exising annotations, the imported annotations + # will be appended to the existing annotations, and thus updated annotation + # would have both exisiting + imported annotations, but we only want the + # imported annotations parent_job = JobDataProvider(parent_job_id) @@ -73,11 +71,10 @@ def merge_task(task: Task, request) -> Response: rq_id = request.data.get('rq_id', f"merge_consensus:task.id{task.id}-by-{request.user}") rq_job = queue.fetch_job(rq_id) user_id = request.user.id - last_instance_update_time = timezone.localtime(task.updated_date) if rq_job: if rq_job.is_finished: - returned_data = rq_job.return_value() + # returned_data = rq_job.return_value() rq_job.delete() return Response(status=status.HTTP_201_CREATED) if returned_data == 201 else Response(status=status.HTTP_400_BAD_REQUEST) elif rq_job.is_failed: From 6012bd2d863736171782722ea265035b6ef3973e Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 4 Jul 2024 15:28:39 +0530 Subject: [PATCH 066/301] removed the return status codes in function `_merge_consensus_jobs` --- cvat/apps/consensus/merge_consensus_jobs.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index 32ba97a305c..21a9516a204 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -40,12 +40,7 @@ def _merge_consensus_jobs(task_id: int) -> None: for parent_job_id, job_ids in jobs.items(): consensus_dataset = list(map(get_annotations, job_ids)) - merged_dataset: dm.Dataset = merger(consensus_dataset) - - # check if the merged dataset has annotations - for item in merged_dataset: - if not item.annotations: - return 400 + merged_dataset = merger(consensus_dataset) # delete the existing annotations in the job patch_job_data(parent_job_id, None, PatchAction.DELETE) @@ -61,7 +56,6 @@ def _merge_consensus_jobs(task_id: int) -> None: # updates the annotations in the job patch_job_data(parent_job_id, parent_job.job_data.data.serialize(), PatchAction.UPDATE) - return 201 def merge_task(task: Task, request) -> Response: @@ -76,7 +70,7 @@ def merge_task(task: Task, request) -> Response: if rq_job.is_finished: # returned_data = rq_job.return_value() rq_job.delete() - return Response(status=status.HTTP_201_CREATED) if returned_data == 201 else Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_201_CREATED) #if returned_data == 201 else Response(status=status.HTTP_400_BAD_REQUEST) elif rq_job.is_failed: exc_info = process_failed_job(rq_job) return Response(data=exc_info, From 8bd8f0105dda2f41b17b3b84004c04ad8a5e5161 Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 4 Jul 2024 15:34:25 +0530 Subject: [PATCH 067/301] added `cvat/apps/consensus` to `format_python_code.sh` --- cvat/apps/consensus/merge_consensus_jobs.py | 51 ++-- .../apps/consensus/migrations/0001_initial.py | 2 +- cvat/apps/consensus/models.py | 20 +- cvat/apps/consensus/new_intersect_merge.py | 279 ++++++++++++------ cvat/apps/consensus/permissions.py | 13 +- cvat/apps/consensus/serializers.py | 15 +- cvat/apps/consensus/signals.py | 14 +- cvat/apps/consensus/urls.py | 4 +- cvat/apps/consensus/views.py | 47 +-- dev/format_python_code.sh | 1 + 10 files changed, 291 insertions(+), 155 deletions(-) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index 21a9516a204..6204ea9c512 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -2,32 +2,38 @@ # # SPDX-License-Identifier: MIT -from typing import List, Dict -import datumaro as dm +from typing import Dict, List + import django_rq -from django.conf import settings from datumaro.components.operations import IntersectMerge -from cvat.apps.quality_control.quality_reports import JobDataProvider -from cvat.apps.engine.models import Task, Job, JobType -from cvat.apps.dataset_manager.bindings import import_dm_annotations +from django.conf import settings +from django.db import transaction +from django.utils import timezone from rest_framework import status from rest_framework.response import Response -from cvat.apps.dataset_manager.task import patch_job_data, PatchAction -from cvat.apps.engine.utils import get_rq_lock_by_user, get_rq_job_meta, define_dependent_job, process_failed_job + +import datumaro as dm +from cvat.apps.dataset_manager.bindings import import_dm_annotations +from cvat.apps.dataset_manager.task import PatchAction, patch_job_data +from cvat.apps.engine.models import Job, JobType, Task from cvat.apps.engine.serializers import RqIdSerializer -from django.utils import timezone -from django.db import transaction +from cvat.apps.engine.utils import (define_dependent_job, get_rq_job_meta, + get_rq_lock_by_user, process_failed_job) +from cvat.apps.quality_control.quality_reports import JobDataProvider def get_consensus_jobs(task_id: int) -> Dict[int, List[int]]: - jobs = {} # parent_job_id -> [consensus_job_id] + jobs = {} # parent_job_id -> [consensus_job_id] - for job in Job.objects.select_related("segment").filter(segment__task_id=task_id, type=JobType.CONSENSUS.value): - assert job.parent_job_id - jobs.setdefault(job.parent_job_id, []).append(job.id) + for job in Job.objects.select_related("segment").filter( + segment__task_id=task_id, type=JobType.CONSENSUS.value + ): + assert job.parent_job_id + jobs.setdefault(job.parent_job_id, []).append(job.id) return jobs + def get_annotations(job_id: int) -> dm.Dataset: return JobDataProvider(job_id).dm_dataset @@ -55,14 +61,18 @@ def _merge_consensus_jobs(task_id: int) -> None: import_dm_annotations(merged_dataset, parent_job.job_data) # updates the annotations in the job - patch_job_data(parent_job_id, parent_job.job_data.data.serialize(), PatchAction.UPDATE) + patch_job_data( + parent_job_id, parent_job.job_data.data.serialize(), PatchAction.UPDATE + ) def merge_task(task: Task, request) -> Response: - queue_name=settings.CVAT_QUEUES.CONSENSUS.value + queue_name = settings.CVAT_QUEUES.CONSENSUS.value queue = django_rq.get_queue(queue_name) # so a user doesn't create requests to merge same task multiple times - rq_id = request.data.get('rq_id', f"merge_consensus:task.id{task.id}-by-{request.user}") + rq_id = request.data.get( + "rq_id", f"merge_consensus:task.id{task.id}-by-{request.user}" + ) rq_job = queue.fetch_job(rq_id) user_id = request.user.id @@ -70,11 +80,12 @@ def merge_task(task: Task, request) -> Response: if rq_job.is_finished: # returned_data = rq_job.return_value() rq_job.delete() - return Response(status=status.HTTP_201_CREATED) #if returned_data == 201 else Response(status=status.HTTP_400_BAD_REQUEST) + return Response( + status=status.HTTP_201_CREATED + ) # if returned_data == 201 else Response(status=status.HTTP_400_BAD_REQUEST) elif rq_job.is_failed: exc_info = process_failed_job(rq_job) - return Response(data=exc_info, - status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response(data=exc_info, status=status.HTTP_500_INTERNAL_SERVER_ERROR) else: # rq_job is in queued stage or might be running return Response(status=status.HTTP_202_ACCEPTED) diff --git a/cvat/apps/consensus/migrations/0001_initial.py b/cvat/apps/consensus/migrations/0001_initial.py index f2f24844608..86c1c476f66 100644 --- a/cvat/apps/consensus/migrations/0001_initial.py +++ b/cvat/apps/consensus/migrations/0001_initial.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.11 on 2024-06-27 11:47 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/consensus/models.py b/cvat/apps/consensus/models.py index c9eed370c22..3a62928826e 100644 --- a/cvat/apps/consensus/models.py +++ b/cvat/apps/consensus/models.py @@ -1,13 +1,19 @@ -from django.db import models -from typing import Any from copy import deepcopy -from cvat.apps.engine.models import Job, ShapeType, Task +from typing import Any + +from django.db import models from django.forms.models import model_to_dict +from cvat.apps.engine.models import Job, ShapeType, Task + class ConsensusSettings(models.Model): task = models.ForeignKey( - Task, on_delete=models.CASCADE, related_name="consensus_settings", null=True, blank=True + Task, + on_delete=models.CASCADE, + related_name="consensus_settings", + null=True, + blank=True, ) agreement_score_threshold = models.FloatField(default=0) quorum = models.IntegerField(default=0) @@ -23,7 +29,11 @@ def organization_id(self): class ConsensusReport(models.Model): task = models.ForeignKey( - Task, on_delete=models.CASCADE, related_name="consensus_reports", null=True, blank=True + Task, + on_delete=models.CASCADE, + related_name="consensus_reports", + null=True, + blank=True, ) created_date = models.DateTimeField(auto_now_add=True) data = models.JSONField() diff --git a/cvat/apps/consensus/new_intersect_merge.py b/cvat/apps/consensus/new_intersect_merge.py index e6e46dc3faf..53dbf37e4c8 100644 --- a/cvat/apps/consensus/new_intersect_merge.py +++ b/cvat/apps/consensus/new_intersect_merge.py @@ -7,53 +7,38 @@ import logging as log from collections import OrderedDict from copy import deepcopy -from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Type, Union +from typing import (Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, + Type, Union) from unittest import TestCase import attr import cv2 import numpy as np from attr import attrib, attrs - -from datumaro.components.annotation import ( - Annotation, - AnnotationType, - Bbox, - Label, - LabelCategories, - MaskCategories, - PointsCategories, -) +from datumaro.components.annotation import (Annotation, AnnotationType, Bbox, + Label, LabelCategories, + MaskCategories, PointsCategories) from datumaro.components.cli_plugin import CliPlugin from datumaro.components.dataset import Dataset, DatasetItemStorage, IDataset -from datumaro.components.errors import ( - AnnotationsTooCloseError, - ConflictingCategoriesError, - DatasetMergeError, - FailedAttrVotingError, - FailedLabelVotingError, - MediaTypeError, - MismatchingAttributesError, - MismatchingImageInfoError, - MismatchingMediaError, - MismatchingMediaPathError, - NoMatchingAnnError, - NoMatchingItemError, - VideoMergeError, - WrongGroupError, -) +from datumaro.components.errors import (AnnotationsTooCloseError, + ConflictingCategoriesError, + DatasetMergeError, + FailedAttrVotingError, + FailedLabelVotingError, MediaTypeError, + MismatchingAttributesError, + MismatchingImageInfoError, + MismatchingMediaError, + MismatchingMediaPathError, + NoMatchingAnnError, + NoMatchingItemError, VideoMergeError, + WrongGroupError) from datumaro.components.extractor import CategoriesInfo, DatasetItem -from datumaro.components.media import Image, MediaElement, MultiframeImage, PointCloud, Video +from datumaro.components.media import (Image, MediaElement, MultiframeImage, + PointCloud, Video) from datumaro.util import filter_dict, find -from datumaro.util.annotation_util import ( - OKS, - approximate_line, - bbox_iou, - find_instances, - max_bbox, - mean_bbox, - segment_iou, -) +from datumaro.util.annotation_util import (OKS, approximate_line, bbox_iou, + find_instances, max_bbox, mean_bbox, + segment_iou) from datumaro.util.attrs_util import default_if_none, ensure_cls @@ -140,7 +125,9 @@ def merge(cls, *sources: IDataset) -> DatasetItemStorage: return items @classmethod - def _merge_items(cls, existing_item: DatasetItem, current_item: DatasetItem) -> DatasetItem: + def _merge_items( + cls, existing_item: DatasetItem, current_item: DatasetItem + ) -> DatasetItem: return existing_item.wrap( media=cls._merge_media(existing_item, current_item), attributes=cls._merge_attrs( @@ -148,11 +135,15 @@ def _merge_items(cls, existing_item: DatasetItem, current_item: DatasetItem) -> current_item.attributes, item_id=(existing_item.id, existing_item.subset), ), - annotations=cls._merge_anno(existing_item.annotations, current_item.annotations), + annotations=cls._merge_anno( + existing_item.annotations, current_item.annotations + ), ) @staticmethod - def _merge_attrs(a: Dict[str, Any], b: Dict[str, Any], item_id: Tuple[str, str]) -> Dict: + def _merge_attrs( + a: Dict[str, Any], b: Dict[str, Any], item_id: Tuple[str, str] + ) -> Dict: merged = {} for name in a.keys() | b.keys(): @@ -195,7 +186,9 @@ def _merge_media( elif (not item_a.media or isinstance(item_a.media, MediaElement)) and ( not item_b.media or isinstance(item_b.media, MediaElement) ): - if isinstance(item_a.media, MediaElement) and isinstance(item_b.media, MediaElement): + if isinstance(item_a.media, MediaElement) and isinstance( + item_b.media, MediaElement + ): if ( item_a.media.path and item_b.media.path @@ -215,7 +208,9 @@ def _merge_media( else: media = item_b.media else: - raise MismatchingMediaError((item_a.id, item_a.subset), item_a.media, item_b.media) + raise MismatchingMediaError( + (item_a.id, item_a.subset), item_a.media, item_b.media + ) return media @staticmethod @@ -286,8 +281,14 @@ def _merge_images(item_a: DatasetItem, item_b: DatasetItem) -> Image: def _merge_point_clouds(item_a: DatasetItem, item_b: DatasetItem) -> PointCloud: media = None - if isinstance(item_a.media, PointCloud) and isinstance(item_b.media, PointCloud): - if item_a.media.path and item_b.media.path and item_a.media.path != item_b.media.path: + if isinstance(item_a.media, PointCloud) and isinstance( + item_b.media, PointCloud + ): + if ( + item_a.media.path + and item_b.media.path + and item_a.media.path != item_b.media.path + ): raise MismatchingMediaPathError( (item_a.id, item_a.subset), item_a.media.path, item_b.media.path ) @@ -336,11 +337,19 @@ def _merge_videos(item_a: DatasetItem, item_b: DatasetItem) -> Video: return media @staticmethod - def _merge_multiframe_images(item_a: DatasetItem, item_b: DatasetItem) -> MultiframeImage: + def _merge_multiframe_images( + item_a: DatasetItem, item_b: DatasetItem + ) -> MultiframeImage: media = None - if isinstance(item_a.media, MultiframeImage) and isinstance(item_b.media, MultiframeImage): - if item_a.media.path and item_b.media.path and item_a.media.path != item_b.media.path: + if isinstance(item_a.media, MultiframeImage) and isinstance( + item_b.media, MultiframeImage + ): + if ( + item_a.media.path + and item_b.media.path + and item_a.media.path != item_b.media.path + ): raise MismatchingMediaPathError( (item_a.id, item_a.subset), item_a.media.path, item_b.media.path ) @@ -368,7 +377,9 @@ def _merge_multiframe_images(item_a: DatasetItem, item_b: DatasetItem) -> Multif return media @staticmethod - def _merge_anno(a: Iterable[Annotation], b: Iterable[Annotation]) -> List[Annotation]: + def _merge_anno( + a: Iterable[Annotation], b: Iterable[Annotation] + ) -> List[Annotation]: return merge_annotations_equal(a, b) @staticmethod @@ -437,7 +448,8 @@ def add_item_error(self, error, *args, **kwargs): def __call__(self, datasets): self._categories = self._merge_categories([d.categories() for d in datasets]) merged = Dataset( - categories=self._categories, media_type=ExactMerge.merge_media_types(datasets) + categories=self._categories, + media_type=ExactMerge.merge_media_types(datasets), ) self._check_groups_definition() @@ -469,13 +481,16 @@ def merge_items(self, items): self._ann_map.update({id(a): (a, id(item)) for a in item.annotations}) sources.append(item.annotations) log.debug( - "Merging item %s: source annotations %s" % (self._item_id, list(map(len, sources))) + "Merging item %s: source annotations %s" + % (self._item_id, list(map(len, sources))) ) annotations = self.merge_annotations(sources) annotations = [ - a for a in annotations if self.conf.output_conf_thresh <= a.attributes.get("score", 1) + a + for a in annotations + if self.conf.output_conf_thresh <= a.attributes.get("score", 1) ] return self._item.wrap(annotations=annotations) @@ -498,12 +513,16 @@ def merge_annotations(self, sources): for merged_ann, cluster in zip(merged_clusters, clusters): attributes = self._find_cluster_attrs(cluster, merged_ann) attributes = { - k: v for k, v in attributes.items() if k not in self.conf.ignored_attributes + k: v + for k, v in attributes.items() + if k not in self.conf.ignored_attributes } attributes.update(merged_ann.attributes) merged_ann.attributes = attributes - new_group_id = find(enumerate(group_map), lambda e: id(cluster) in e[1][0]) + new_group_id = find( + enumerate(group_map), lambda e: id(cluster) in e[1][0] + ) if new_group_id is None: new_group_id = 0 else: @@ -570,7 +589,12 @@ def _merge_label_categories(self, sources): raise ConflictingCategoriesError( "Can't merge label category %s (from #%s): " "parent label conflict: %s vs. %s" - % (src_label.name, src_id, src_label.parent, dst_label.parent), + % ( + src_label.name, + src_id, + src_label.parent, + dst_label.parent, + ), sources=list(range(src_id)), ) dst_label.parent = dst_label.parent or src_label.parent @@ -600,7 +624,8 @@ def _merge_point_categories(self, sources, label_cat): if dst_cat != src_cat: raise ConflictingCategoriesError( "Can't merge point category for label " - "%s (from #%s): %s vs. %s" % (src_label, src_id, src_cat, dst_cat), + "%s (from #%s): %s vs. %s" + % (src_label, src_id, src_cat, dst_cat), sources=list(range(src_id)), ) else: @@ -631,7 +656,8 @@ def _merge_mask_categories(self, sources, label_cat): if dst_cat != src_cat: raise ConflictingCategoriesError( "Can't merge mask category for label " - "%s (from #%s): %s vs. %s" % (src_label, src_id, src_cat, dst_cat), + "%s (from #%s): %s vs. %s" + % (src_label, src_id, src_cat, dst_cat), sources=list(range(src_id)), ) else: @@ -719,13 +745,19 @@ def _for_type(t, **kwargs): a for a in inst if a.type - in {AnnotationType.polygon, AnnotationType.mask, AnnotationType.bbox} + in { + AnnotationType.polygon, + AnnotationType.mask, + AnnotationType.bbox, + } ] ) for ann in inst: instance_map[id(ann)] = [inst, inst_bbox] - self._mergers = {t: _for_type(t, instance_map=instance_map) for t in AnnotationType} + self._mergers = { + t: _for_type(t, instance_map=instance_map) for t in AnnotationType + } def _match_ann_type(self, t, sources): return self._mergers[t].match_annotations(sources) @@ -817,8 +849,12 @@ def _has_item(s): return False return True - missing_sources = set(self._dataset_map) - set(self.get_ann_source(id(a)) for a in cluster) - missing_sources = [self._dataset_map[s][1] for s in missing_sources if _has_item(s)] + missing_sources = set(self._dataset_map) - set( + self.get_ann_source(id(a)) for a in cluster + ) + missing_sources = [ + self._dataset_map[s][1] for s in missing_sources if _has_item(s) + ] if missing_sources: self.add_item_error(NoMatchingAnnError, cluster[0], sources=missing_sources) @@ -842,7 +878,9 @@ def _check_group(group_labels, group): real_miss = check_group - common - optional extra = group_labels - check_group if common and (extra or real_miss): - self.add_item_error(WrongGroupError, group_labels, check_group, group) + self.add_item_error( + WrongGroupError, group_labels, check_group, group + ) break groups = find_instances(annotations) @@ -878,7 +916,10 @@ def _get_src_label_name(self, ann, label_id): item_id = self._ann_map[id(ann)][1] dataset_id = self._item_map[item_id][1] return ( - self._dataset_map[dataset_id][0].categories()[AnnotationType.label].items[label_id].name + self._dataset_map[dataset_id][0] + .categories()[AnnotationType.label] + .items[label_id] + .name ) def _get_any_label_name(self, ann, label_id): @@ -897,7 +938,13 @@ def _check_groups_definition(self): raise ValueError( "Datasets do not contain " "label '%s', available labels %s" - % (label, [i.name for i in self._categories[AnnotationType.label].items]) + % ( + label, + [ + i.name + for i in self._categories[AnnotationType.label].items + ], + ) ) @@ -1124,10 +1171,13 @@ def merge_clusters(self, clusters): sources = set( self.get_ann_source(id(a)) for a in clusters[0] - if label not in [self._context._get_src_label_name(l, l.label) for l in a] + if label + not in [self._context._get_src_label_name(l, l.label) for l in a] ) sources = [self._context._dataset_map[s][1] for s in sources] - self._context.add_item_error(FailedLabelVotingError, votes, sources=sources) + self._context.add_item_error( + FailedLabelVotingError, votes, sources=sources + ) continue merged.append( @@ -1175,7 +1225,9 @@ def _merge_cluster_shape_mean_box_nearest(cluster): def merge_cluster_shape(self, cluster): shape = self._merge_cluster_shape_mean_box_nearest(cluster) - shape_score = sum(max(0, self.distance(shape, s)) for s in cluster) / len(cluster) + shape_score = sum(max(0, self.distance(shape, s)) for s in cluster) / len( + cluster + ) return shape, shape_score def merge_cluster(self, cluster): @@ -1189,7 +1241,9 @@ def merge_cluster(self, cluster): # return None shape.z_order = max(cluster, key=lambda a: a.z_order).z_order shape.label = label - shape.attributes["score"] = label_score * shape_score if label is not None else shape_score + shape.attributes["score"] = ( + label_score * shape_score if label is not None else shape_score + ) return shape @@ -1239,7 +1293,9 @@ def merge_cluster(self, cluster): shape, shape_score = self.merge_cluster_shape(cluster) shape.label = label - shape.attributes["score"] = label_score * shape_score if label is not None else shape_score + shape.attributes["score"] = ( + label_score * shape_score if label is not None else shape_score + ) return shape @@ -1348,7 +1404,9 @@ def accumulate(self, item: DatasetItem): self._stats[(item.id, item.subset)] = (count, mean, std) - def get_result(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]: + def get_result( + self, + ) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]: n = len(self._stats) if n == 0: @@ -1366,7 +1424,11 @@ def get_result(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, fl var = lambda i, s: s[i][1] # make variance unbiased - np.multiply(np.square(stats[:, 1]), (counts / (counts - 1))[:, np.newaxis], out=stats[:, 1]) + np.multiply( + np.square(stats[:, 1]), + (counts / (counts - 1))[:, np.newaxis], + out=stats[:, 1], + ) # Use an online algorithm to: # - handle different image sizes @@ -1396,7 +1458,11 @@ def _pairwise_stats(count_a, mean_a, var_a, count_b, mean_b, var_b): m_b = var_b * (count_b - 1) M2 = m_a + m_b + delta**2 * (count_a * count_b / (count_a + count_b)) - return (count_a + count_b, mean_a * 0.5 + mean_b * 0.5, M2 / (count_a + count_b - 1)) + return ( + count_a + count_b, + mean_a * 0.5 + mean_b * 0.5, + M2 / (count_a + count_b - 1), + ) @staticmethod def _compute_stats(stats, counts, mean_accessor, variance_accessor): @@ -1529,7 +1595,9 @@ def get_label(ann): } label_stat = { "count": 0, - "distribution": {l.name: [0, 0] for l in labels.items}, # label -> (count, total%) + "distribution": { + l.name: [0, 0] for l in labels.items + }, # label -> (count, total%) "attributes": {}, } stats["annotations"]["labels"] = label_stat @@ -1537,7 +1605,9 @@ def get_label(ann): "avg. area": 0, "area distribution": [], # a histogram with 10 bins # (min, min+10%), ..., (min+90%, max) -> (count, total%) - "pixel distribution": {l.name: [0, 0] for l in labels.items}, # label -> (count, total%) + "pixel distribution": { + l.name: [0, 0] for l in labels.items + }, # label -> (count, total%) } stats["annotations"]["segments"] = segm_stat segm_areas = [] @@ -1555,7 +1625,11 @@ def get_label(ann): if not hasattr(ann, "label") or ann.label is None: continue - if ann.type in {AnnotationType.mask, AnnotationType.polygon, AnnotationType.bbox}: + if ann.type in { + AnnotationType.mask, + AnnotationType.polygon, + AnnotationType.bbox, + }: area = ann.get_area() segm_areas.append(area) pixel_dist[get_label(ann)][0] += int(area) @@ -1564,16 +1638,26 @@ def get_label(ann): label_stat["distribution"][get_label(ann)][0] += 1 for name, value in ann.attributes.items(): - if name.lower() in {"occluded", "visibility", "score", "id", "track_id"}: + if name.lower() in { + "occluded", + "visibility", + "score", + "id", + "track_id", + }: continue - attrs_stat = label_stat["attributes"].setdefault(name, deepcopy(attr_template)) + attrs_stat = label_stat["attributes"].setdefault( + name, deepcopy(attr_template) + ) attrs_stat["count"] += 1 attrs_stat["values present"].add(str(value)) attrs_stat["distribution"].setdefault(str(value), [0, 0])[0] += 1 stats["images count"] = len(dataset) - stats["annotations count"] = sum(t["count"] for t in stats["annotations by type"].values()) + stats["annotations count"] = sum( + t["count"] for t in stats["annotations by type"].values() + ) stats["unannotated images count"] = len(stats["unannotated images"]) for label_info in label_stat["distribution"].values(): @@ -1639,8 +1723,12 @@ def _get_ann_type(t, item): return [a for a in item.annotations if a.type == t] def match_labels(self, item_a, item_b): - a_labels = set(a.label for a in self._get_ann_type(AnnotationType.label, item_a)) - b_labels = set(a.label for a in self._get_ann_type(AnnotationType.label, item_b)) + a_labels = set( + a.label for a in self._get_ann_type(AnnotationType.label, item_a) + ) + b_labels = set( + a.label for a in self._get_ann_type(AnnotationType.label, item_b) + ) matches = a_labels & b_labels a_unmatched = a_labels - b_labels @@ -1675,7 +1763,10 @@ def match_points(self, item_a, item_b): matcher = PointsMatcher(instance_map=instance_map) return match_segments( - a_points, b_points, dist_thresh=self.iou_threshold, distance=matcher.distance + a_points, + b_points, + dist_thresh=self.iou_threshold, + distance=matcher.distance, ) def match_lines(self, item_a, item_b): @@ -1726,7 +1817,9 @@ def _default_item_hash(item: DatasetItem): return hash(item.media.path) log.warning( - "Item (%s, %s) has no image " "info, counted as unique", item.id, item.subset + "Item (%s, %s) has no image " "info, counted as unique", + item.id, + item.subset, ) return None @@ -1775,7 +1868,9 @@ class ExactComparator: match_images: bool = attrib(kw_only=True, default=False) ignored_fields = attrib(kw_only=True, factory=set, validator=default_if_none(set)) ignored_attrs = attrib(kw_only=True, factory=set, validator=default_if_none(set)) - ignored_item_attrs = attrib(kw_only=True, factory=set, validator=default_if_none(set)) + ignored_item_attrs = attrib( + kw_only=True, factory=set, validator=default_if_none(set) + ) _test: TestCase = attrib(init=False) errors: list = attrib(init=False) @@ -1795,7 +1890,9 @@ def _compare_categories(self, a, b): errors = self.errors try: - test.assertEqual(sorted(a, key=lambda t: t.value), sorted(b, key=lambda t: t.value)) + test.assertEqual( + sorted(a, key=lambda t: t.value), sorted(b, key=lambda t: t.value) + ) except AssertionError as e: errors.append({"type": "categories", "message": str(e)}) @@ -1854,14 +1951,18 @@ def _compare_items(self, item_a, item_b): filter_dict(item_b.attributes, self.ignored_item_attrs), ) except AssertionError as e: - errors.append({"type": "item_attr", "a_item": a_id, "b_item": b_id, "message": str(e)}) + errors.append( + {"type": "item_attr", "a_item": a_id, "b_item": b_id, "message": str(e)} + ) b_annotations = item_b.annotations[:] for ann_a in item_a.annotations: ann_b_candidates = [x for x in item_b.annotations if x.type == ann_a.type] ann_b = find( - enumerate(self._compare_annotations(ann_a, x) for x in ann_b_candidates), + enumerate( + self._compare_annotations(ann_a, x) for x in ann_b_candidates + ), lambda x: x[1], ) if ann_b is None: @@ -1877,7 +1978,9 @@ def _compare_items(self, item_a, item_b): ann_b = ann_b_candidates[ann_b[0]] b_annotations.remove(ann_b) # avoid repeats - matched.append({"a_item": a_id, "b_item": b_id, "a": str(ann_a), "b": str(ann_b)}) + matched.append( + {"a_item": a_id, "b_item": b_id, "a": str(ann_a), "b": str(ann_b)} + ) for ann_b in b_annotations: unmatched.append({"item": b_id, "source": "b", "ann": str(ann_b)}) @@ -1895,7 +1998,9 @@ def compare_datasets(self, a, b): matches, a_unmatched, b_unmatched = self._match_items(a, b) - if a.categories().get(AnnotationType.label) != b.categories().get(AnnotationType.label): + if a.categories().get(AnnotationType.label) != b.categories().get( + AnnotationType.label + ): return matched, unmatched, a_unmatched, b_unmatched, errors _dist = lambda s: len(s[1]) + len(s[2]) diff --git a/cvat/apps/consensus/permissions.py b/cvat/apps/consensus/permissions.py index a8e97c480d9..76cbb75abc0 100644 --- a/cvat/apps/consensus/permissions.py +++ b/cvat/apps/consensus/permissions.py @@ -10,7 +10,8 @@ from cvat.apps.engine.models import Task from cvat.apps.engine.permissions import TaskPermission -from cvat.apps.iam.permissions import OpenPolicyAgentPermission, StrEnum, get_iam_context +from cvat.apps.iam.permissions import (OpenPolicyAgentPermission, StrEnum, + get_iam_context) from .models import ConsensusSettings @@ -44,7 +45,11 @@ def create(cls, request, view, obj, iam_context): # This component doesn't define its own rules in this case permissions.append( TaskPermission.create_base_perm( - request, view, iam_context=iam_context, scope=task_scope, obj=obj.task + request, + view, + iam_context=iam_context, + scope=task_scope, + obj=obj.task, ) ) elif scope == cls.Scopes.LIST: @@ -59,7 +64,9 @@ def create(cls, request, view, obj, iam_context): permissions.append(cls.create_scope_list(request, iam_context)) else: - permissions.append(cls.create_base_perm(request, view, scope, iam_context, obj)) + permissions.append( + cls.create_base_perm(request, view, scope, iam_context, obj) + ) return permissions diff --git a/cvat/apps/consensus/serializers.py b/cvat/apps/consensus/serializers.py index 262fe2766a2..41109620ed8 100644 --- a/cvat/apps/consensus/serializers.py +++ b/cvat/apps/consensus/serializers.py @@ -1,8 +1,11 @@ +import textwrap + +from django.db import IntegrityError, models, transaction from rest_framework import serializers + from cvat.apps.consensus.models import ConsensusSettings from cvat.apps.engine.models import Task -from django.db import IntegrityError, models, transaction -import textwrap + class ConsensusSettingsSerializer(serializers.ModelSerializer): class Meta: @@ -38,9 +41,13 @@ def validate(self, attrs): for k, v in attrs.items(): if k.endswith("_threshold"): if not 0 <= v <= 1: - raise serializers.ValidationError(f"{k} must be in the range [0; 1]") + raise serializers.ValidationError( + f"{k} must be in the range [0; 1]" + ) elif k == "quorum": if not 0 <= v <= 10: - raise serializers.ValidationError(f"{k} must be in the range [0; 10]") + raise serializers.ValidationError( + f"{k} must be in the range [0; 10]" + ) return super().validate(attrs) diff --git a/cvat/apps/consensus/signals.py b/cvat/apps/consensus/signals.py index bba9b5f3a7d..be161a80c5c 100644 --- a/cvat/apps/consensus/signals.py +++ b/cvat/apps/consensus/signals.py @@ -6,13 +6,21 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from cvat.apps.engine.models import Annotation, Job, Project, Task # from cvat.apps.quality_control import quality_reports as qc from cvat.apps.consensus.models import ConsensusSettings +from cvat.apps.engine.models import Annotation, Job, Project, Task -@receiver(post_save, sender=Task, dispatch_uid=__name__ + ".save_task-initialize_consensus_settings") -@receiver(post_save, sender=Job, dispatch_uid=__name__ + ".save_job-initialize_consensus_settings") +@receiver( + post_save, + sender=Task, + dispatch_uid=__name__ + ".save_task-initialize_consensus_settings", +) +@receiver( + post_save, + sender=Job, + dispatch_uid=__name__ + ".save_job-initialize_consensus_settings", +) def __save_task__initialize_consensus_settings(instance, created, **kwargs): # Initializes default quality settings for the task # this is done in a signal to decouple this component from the engine app diff --git a/cvat/apps/consensus/urls.py b/cvat/apps/consensus/urls.py index 0654ac2291e..6ff55a02cca 100644 --- a/cvat/apps/consensus/urls.py +++ b/cvat/apps/consensus/urls.py @@ -9,7 +9,9 @@ router = routers.DefaultRouter(trailing_slash=False) # router.register("reports", views.ConsensusReportViewSet, basename="consensus_reports") -router.register("settings", views.ConsensusSettingsViewSet, basename="consensus_settings") +router.register( + "settings", views.ConsensusSettingsViewSet, basename="consensus_settings" +) urlpatterns = [ # entry point for API diff --git a/cvat/apps/consensus/views.py b/cvat/apps/consensus/views.py index afd0ce92b6f..622c52586dc 100644 --- a/cvat/apps/consensus/views.py +++ b/cvat/apps/consensus/views.py @@ -7,49 +7,32 @@ 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 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.consensus.models import ConsensusSettings +from cvat.apps.consensus.permissions import ConsensusSettingPermission +from cvat.apps.consensus.serializers import ConsensusSettingsSerializer 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.quality_control import quality_reports as qc -from cvat.apps.quality_control.models import ( - AnnotationConflict, - QualityReport, - QualityReportTarget, - QualitySettings, -) +from cvat.apps.quality_control.models import (AnnotationConflict, + QualityReport, + QualityReportTarget, + QualitySettings) from cvat.apps.quality_control.permissions import ( - AnnotationConflictPermission, - QualityReportPermission, - QualitySettingPermission, -) + AnnotationConflictPermission, QualityReportPermission, + QualitySettingPermission) from cvat.apps.quality_control.serializers import ( - AnnotationConflictSerializer, - QualityReportCreateSerializer, - QualityReportSerializer, - QualitySettingsSerializer, -) + AnnotationConflictSerializer, QualityReportCreateSerializer, + QualityReportSerializer, QualitySettingsSerializer) -from cvat.apps.consensus.serializers import ( - ConsensusSettingsSerializer -) -from cvat.apps.consensus.models import ( - ConsensusSettings -) -from cvat.apps.consensus.permissions import ( - ConsensusSettingPermission -) """ engine> views.py> TaskViewSet @@ -112,7 +95,9 @@ class ConsensusSettingsViewSet( mixins.RetrieveModelMixin, PartialUpdateModelMixin, ): - queryset = ConsensusSettings.objects.select_related("task", "task__organization").all() + queryset = ConsensusSettings.objects.select_related( + "task", "task__organization" + ).all() iam_organization_field = "task__organization" diff --git a/dev/format_python_code.sh b/dev/format_python_code.sh index a67bf08572e..8d4aa8e1295 100755 --- a/dev/format_python_code.sh +++ b/dev/format_python_code.sh @@ -23,6 +23,7 @@ for paths in \ "tests/python/" \ "cvat/apps/quality_control" \ "cvat/apps/analytics_report" \ + "cvat/apps/consensus" \ ; do ${BLACK} -- ${paths} ${ISORT} -- ${paths} From fd90a2a77e5f6da4e98d970f0d754668d7e023a0 Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 4 Jul 2024 23:40:14 +0530 Subject: [PATCH 068/301] setting `consensus_settings` default value same as that of CLI merge command of datumaro --- cvat/apps/consensus/merge_consensus_jobs.py | 9 ++++++++- cvat/apps/consensus/models.py | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index 6204ea9c512..c3a1b6bbfd8 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -41,7 +41,14 @@ def get_annotations(job_id: int) -> dm.Dataset: @transaction.atomic def _merge_consensus_jobs(task_id: int) -> None: jobs = get_consensus_jobs(task_id) - merger = IntersectMerge() + consensus_settings = ConsensusSettings.objects.filter(task=task_id).first() + merger = IntersectMerge( + conf=IntersectMerge.Conf( + pairwise_dist=consensus_settings.iou_threshold, + output_conf_thresh=consensus_settings.agreement_score_threshold, + quorum=consensus_settings.quorum, + ) + ) for parent_job_id, job_ids in jobs.items(): consensus_dataset = list(map(get_annotations, job_ids)) diff --git a/cvat/apps/consensus/models.py b/cvat/apps/consensus/models.py index 3a62928826e..4e67cf6b89d 100644 --- a/cvat/apps/consensus/models.py +++ b/cvat/apps/consensus/models.py @@ -17,7 +17,7 @@ class ConsensusSettings(models.Model): ) agreement_score_threshold = models.FloatField(default=0) quorum = models.IntegerField(default=0) - iou_threshold = models.FloatField(default=0) + iou_threshold = models.FloatField(default=0.5) def to_dict(self): return model_to_dict(self) From b24e0809bbd038d6f503442fb219fedf03e11f89 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 5 Jul 2024 23:56:30 +0530 Subject: [PATCH 069/301] added pyproject.toml for black formating --- cvat/apps/consensus/pyproject.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 cvat/apps/consensus/pyproject.toml diff --git a/cvat/apps/consensus/pyproject.toml b/cvat/apps/consensus/pyproject.toml new file mode 100644 index 00000000000..567b7836258 --- /dev/null +++ b/cvat/apps/consensus/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 8db74e6f6fe254120fd72a3436676c019cccb1ca Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 5 Jul 2024 23:57:56 +0530 Subject: [PATCH 070/301] added `consensus_reports` and `consensus_conflict` in the backend thus API access available --- cvat/apps/consensus/consensus_reports.py | 471 ++++++++++++++++++ cvat/apps/consensus/merge_consensus_jobs.py | 47 +- .../apps/consensus/migrations/0001_initial.py | 111 ++++- cvat/apps/consensus/models.py | 123 ++++- cvat/apps/consensus/new_intersect_merge.py | 220 +++----- cvat/apps/consensus/permissions.py | 159 +++++- cvat/apps/consensus/rules/conflicts.rego | 118 +++++ .../consensus/rules/consensus_reports.rego | 118 +++++ cvat/apps/consensus/serializers.py | 67 ++- cvat/apps/consensus/urls.py | 7 +- cvat/apps/consensus/views.py | 308 +++++++++++- 11 files changed, 1537 insertions(+), 212 deletions(-) create mode 100644 cvat/apps/consensus/consensus_reports.py create mode 100644 cvat/apps/consensus/rules/conflicts.rego create mode 100644 cvat/apps/consensus/rules/consensus_reports.rego diff --git a/cvat/apps/consensus/consensus_reports.py b/cvat/apps/consensus/consensus_reports.py new file mode 100644 index 00000000000..ba8bb3c7238 --- /dev/null +++ b/cvat/apps/consensus/consensus_reports.py @@ -0,0 +1,471 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import itertools +import math +from collections import Counter +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 uuid import uuid4 + +import datumaro as dm +import datumaro.util.mask_tools +import django_rq +import numpy as np +from attrs import asdict, define, fields_dict +from datumaro.components.annotation import Annotation +from datumaro.util import dump_json, parse_json +from django.conf import settings +from django.db import transaction +from django.utils import timezone +from scipy.optimize import linear_sum_assignment + +from cvat.apps.consensus import models +from cvat.apps.consensus.models import ( + ConsensusConflict, + ConsensusConflictType, + ConsensusReport, + ConsensusSettings, +) +from cvat.apps.dataset_manager.bindings import ( + CommonData, + CvatToDmAnnotationConverter, + GetCVATDataExtractor, + JobData, + match_dm_item, +) +from cvat.apps.dataset_manager.formats.registry import dm_env +from cvat.apps.dataset_manager.task import JobAnnotation +from cvat.apps.dataset_manager.util import bulk_create +from cvat.apps.engine.models import ( + DimensionType, + Job, + JobType, + ShapeType, + StageChoice, + StatusChoice, + Task, +) +from cvat.apps.profiler import silk_profile +from cvat.apps.quality_control.quality_reports import AnnotationId, JobDataProvider, _Serializable +from cvat.utils.background_jobs import schedule_job_with_throttling + + +@define(kw_only=True) +class AnnotationConflict(_Serializable): + frame_id: int + type: models.ConsensusConflictType + annotation_ids: List[AnnotationId] + + def _value_serializer(self, v): + if isinstance(v, models.ConsensusConflictType): + return str(v) + else: + return super()._value_serializer(v) + + # def _fields_dict(self, *, include_properties: Optional[List[str]] = None) -> dict: + # return super()._fields_dict(include_properties=include_properties or ["severity"]) + + @classmethod + def from_dict(cls, d: dict): + return cls( + frame_id=d["frame_id"], + type=models.ConsensusConflictType(d["type"]), + annotation_ids=list(AnnotationId.from_dict(v) for v in d["annotation_ids"]), + ) + + +@define(kw_only=True) +class ComparisonReportComparisonSummary(_Serializable): + frames: List[str] + + @property + def mean_conflict_count(self) -> float: + return self.conflict_count / (len(self.frames) or 1) + + conflict_count: int + conflicts_by_type: Dict[models.ConsensusConflictType, int] + + @property + def frame_count(self) -> int: + return len(self.frames) + + def _value_serializer(self, v): + if isinstance(v, models.ConsensusConflictType): + return str(v) + else: + return super()._value_serializer(v) + + def _fields_dict(self, *, include_properties: Optional[List[str]] = None) -> dict: + return super()._fields_dict( + include_properties=include_properties + or [ + "frame_count", + "mean_conflict_count", + "conflict_count", + "conflicts_by_type", + ] + ) + + @classmethod + def from_dict(cls, d: dict): + return cls( + frames=list(d["frames"]), + conflict_count=d["conflict_count"], + conflicts_by_type={ + models.ConsensusConflictType(k): v + for k, v in d.get("conflicts_by_type", {}).items() + }, + ) + + +@define(kw_only=True, init=False) +class ComparisonReportFrameSummary(_Serializable): + conflicts: List[AnnotationConflict] + + @cached_property + def conflict_count(self) -> int: + return len(self.conflicts) + + @cached_property + def conflicts_by_type(self) -> Dict[models.ConsensusConflictType, int]: + return Counter(c.type for c in self.conflicts) + + _CACHED_FIELDS = ["conflict_count", "conflicts_by_type"] + + def _value_serializer(self, v): + if isinstance(v, models.ConsensusConflictType): + return str(v) + else: + return super()._value_serializer(v) + + def __init__(self, *args, **kwargs): + # these fields are optional, but can be computed on access + for field_name in self._CACHED_FIELDS: + if field_name in kwargs: + setattr(self, field_name, kwargs.pop(field_name)) + + self.__attrs_init__(*args, **kwargs) + + def _fields_dict(self, *, include_properties: Optional[List[str]] = None) -> dict: + return super()._fields_dict(include_properties=include_properties or self._CACHED_FIELDS) + + @classmethod + def from_dict(cls, d: dict): + optional_fields = set(cls._CACHED_FIELDS) - { + "conflicts_by_type" # requires extra conversion + } + return cls( + **{field: d[field] for field in optional_fields if field in d}, + **( + dict( + conflicts_by_type={ + models.ConsensusConflictType(k): v + for k, v in d["conflicts_by_type"].items() + } + ) + if "conflicts_by_type" in d + else {} + ), + conflicts=[AnnotationConflict.from_dict(v) for v in d["conflicts"]], + ) + + +@define(kw_only=True) +class ComparisonParameters(_Serializable): + # TODO: dm.AnnotationType.skeleton to be implemented + included_annotation_types: List[dm.AnnotationType] = [ + dm.AnnotationType.bbox, + dm.AnnotationType.points, + dm.AnnotationType.mask, + dm.AnnotationType.polygon, + dm.AnnotationType.polyline, + dm.AnnotationType.label, + ] + + # non_groupable_ann_type = dm.AnnotationType.label + # "Annotation type that can't be grouped" + + agreement_score_threshold: float + quorum: int + iou_threshold: float + + def _value_serializer(self, v): + if isinstance(v, dm.AnnotationType): + return str(v.name) + else: + return super()._value_serializer(v) + + @classmethod + def from_dict(cls, d: dict): + fields = fields_dict(cls) + return cls(**{field_name: d[field_name] for field_name in fields if field_name in d}) + + +@define(kw_only=True) +class ComparisonReport(_Serializable): + parameters: ComparisonParameters + comparison_summary: ComparisonReportComparisonSummary + frame_results: Dict[int, ComparisonReportFrameSummary] + + @property + def conflicts(self) -> List[AnnotationConflict]: + return list(itertools.chain.from_iterable(r.conflicts for r in self.frame_results.values())) + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> ComparisonReport: + return cls( + parameters=ComparisonParameters.from_dict(d["parameters"]), + comparison_summary=ComparisonReportComparisonSummary.from_dict(d["comparison_summary"]), + frame_results={ + int(k): ComparisonReportFrameSummary.from_dict(v) + for k, v in d["frame_results"].items() + }, + ) + + def to_json(self) -> str: + d = self.to_dict() + + # String keys are needed for json dumping + d["frame_results"] = {str(k): v for k, v in d["frame_results"].items()} + return dump_json(d).decode() + + @classmethod + def from_json(cls, data: str) -> ComparisonReport: + return cls.from_dict(parse_json(data)) + + +def generate_job_consensus_report( + consensus_settings: ConsensusSettings, + errors, + consensus_job_data_providers: List[JobDataProvider], +) -> ComparisonReport: + + frame_results: Dict[int, ComparisonReportFrameSummary] = {} + frames = set() + conflicts_count = len(errors) + conflicts = [] + + for error in errors: + error_type = str(type(error)).split(".")[-1].split("'")[0] + error_type = ConsensusConflictType[error_type].value + annotation_ids = [] + error_annotations = [] + + for arg in error.args: + if isinstance(arg, Annotation): + error_annotations.append(arg) + + for annotation in error_annotations: + for consensus_job_data_provider in consensus_job_data_providers: + try: + annotation_id = consensus_job_data_provider.dm_ann_to_ann_id(annotation) + break + except KeyError: + pass + + annotation_ids.append(annotation_id) + dm_item = consensus_job_data_providers[0].dm_dataset.get(error.item_id[0]) + frame_id: int = consensus_job_data_providers[0].dm_item_id_to_frame_id(dm_item) + frames.add(frame_id) + frame_results.setdefault(frame_id, []).append( + AnnotationConflict( + frame_id=frame_id, + type=error_type, + annotation_ids=annotation_ids, + ) + ) + + for frame_id in frame_results: + conflicts += frame_results[frame_id] + frame_results[frame_id] = ComparisonReportFrameSummary(conflicts=frame_results[frame_id]) + + return ComparisonReport( + parameters=ComparisonParameters.from_dict(consensus_settings.to_dict()), + comparison_summary=ComparisonReportComparisonSummary( + frames=list(frames), + conflict_count=conflicts_count, + conflicts_by_type=Counter(c.type for c in conflicts), + ), + frame_results=frame_results, + ) + + +def generate_task_consensus_report(job_reports: List[ComparisonReport]) -> ComparisonReport: + task_frames = set() + task_conflicts: List[AnnotationConflict] = [] + task_frame_results = {} + task_frame_results_counts = {} + for r in job_reports.values(): + task_frames.update(r.comparison_summary.frames) + task_conflicts.extend(r.conflicts) + + for frame_id, job_frame_result in r.frame_results.items(): + task_frame_result = cast( + Optional[ComparisonReportFrameSummary], task_frame_results.get(frame_id) + ) + frame_results_count = task_frame_results_counts.get(frame_id, 0) + + if task_frame_result is None: + task_frame_result = deepcopy(job_frame_result) + else: + task_frame_result.conflicts += job_frame_result.conflicts + + task_frame_results_counts[frame_id] = 1 + frame_results_count + task_frame_results[frame_id] = task_frame_result + + task_report_data = ComparisonReport( + parameters=next(iter(job_reports.values())).parameters, + comparison_summary=ComparisonReportComparisonSummary( + frames=sorted(task_frames), + conflict_count=len(task_conflicts), + conflicts_by_type=Counter(c.type for c in task_conflicts), + ), + frame_results=task_frame_results, + ) + return task_report_data + + +def get_last_report_time(task: Task) -> Optional[timezone.datetime]: + report = models.ConsensusReport.objects.filter(task=task).order_by("-created_date").first() + if report: + return report.created_date + return None + + +@transaction.atomic +def save_report( + task_id: int, + jobs: List[Job], + task_report_data: ComparisonReport, + job_report_data: List[ComparisonReport], +): + try: + Task.objects.get(id=task_id) + except Task.DoesNotExist: + return + + task = Task.objects.filter(id=task_id).first() + + # last_report_time = self._get_last_report_time(task) + # if not self.is_custom_quality_check_job(self._get_current_job()) and ( + # last_report_time + # and timezone.now() < last_report_time + self._get_quality_check_job_delay() + # ): + # # Discard this report as it has probably been computed in parallel + # # with another one + # return + + job_reports = {} + for job_id in jobs: + job_comparison_report = job_report_data[job_id] + job = Job.objects.filter(id=job_id).first() + job_report = dict( + job=job, + target_last_updated=job.updated_date, + data=job_comparison_report.to_json(), + conflicts=[c.to_dict() for c in job_comparison_report.conflicts], + ) + + job_reports[job.id] = job_report + + job_reports = list(job_reports.values()) + + task_report = dict( + task=task, + target_last_updated=task.updated_date, + data=task_report_data.to_json(), + conflicts=[], # the task doesn't have own conflicts + ) + + db_task_report = ConsensusReport( + task=task_report["task"], + target_last_updated=task_report["target_last_updated"], + data=task_report["data"], + ) + db_task_report.save() + + db_job_reports = [] + for job_report in job_reports: + db_job_report = ConsensusReport( + job=job_report["job"], + target_last_updated=job_report["target_last_updated"], + data=job_report["data"], + ) + db_job_reports.append(db_job_report) + + db_job_reports = bulk_create(db_model=ConsensusReport, objects=db_job_reports, flt_param={}) + + db_conflicts = [] + db_report_iter = itertools.chain([db_task_report], db_job_reports) + report_iter = itertools.chain([task_report], job_reports) + for report, db_report in zip(report_iter, db_report_iter): + if not db_report.id: + continue + for conflict in report["conflicts"]: + db_conflict = ConsensusConflict( + report=db_report, + type=conflict["type"], + frame=conflict["frame_id"], + ) + db_conflicts.append(db_conflict) + + db_conflicts = bulk_create(db_model=ConsensusConflict, objects=db_conflicts, flt_param={}) + + db_ann_ids = [] + db_conflicts_iter = iter(db_conflicts) + for report in itertools.chain([task_report], job_reports): + for conflict, db_conflict in zip(report["conflicts"], db_conflicts_iter): + for ann_id in conflict["annotation_ids"]: + db_ann_id = models.AnnotationId( + conflict=db_conflict, + job_id=ann_id["job_id"], + obj_id=ann_id["obj_id"], + type=ann_id["type"], + shape_type=ann_id["shape_type"], + ) + db_ann_ids.append(db_ann_id) + + db_ann_ids = bulk_create(db_model=models.AnnotationId, objects=db_ann_ids, flt_param={}) + + return db_task_report.id + + +def prepare_report_for_downloading(db_report: ConsensusReport, *, host: str) -> str: + # Decorate the report for better usability and readability: + # - add conflicting annotation links like: + # /tasks/62/jobs/82?frame=250&type=shape&serverID=33741 + # - convert some fractions to percents + # - add common report info + + task_id = db_report.get_task().id + serialized_data = dict( + job_id=db_report.job.id if db_report.job is not None else None, + task_id=task_id, + created_date=str(db_report.created_date), + target_last_updated=str(db_report.target_last_updated), + ) + + comparison_report = ComparisonReport.from_json(db_report.get_json_report()) + serialized_data.update(comparison_report.to_dict()) + + for frame_result in serialized_data["frame_results"].values(): + for conflict in frame_result["conflicts"]: + for ann_id in conflict["annotation_ids"]: + ann_id["url"] = ( + f"{host}tasks/{task_id}/jobs/{ann_id['job_id']}" + f"?frame={conflict['frame_id']}" + f"&type={ann_id['type']}" + f"&serverID={ann_id['obj_id']}" + ) + + # String keys are needed for json dumping + serialized_data["frame_results"] = { + str(k): v for k, v in serialized_data["frame_results"].items() + } + return dump_json(serialized_data, indent=True, append_newline=True).decode() diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index c3a1b6bbfd8..43032479655 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -3,7 +3,9 @@ # SPDX-License-Identifier: MIT from typing import Dict, List +from uuid import uuid4 +import datumaro as dm import django_rq from datumaro.components.operations import IntersectMerge from django.conf import settings @@ -12,13 +14,23 @@ from rest_framework import status from rest_framework.response import Response -import datumaro as dm +from cvat.apps.consensus.consensus_reports import ( + ComparisonReport, + generate_job_consensus_report, + generate_task_consensus_report, + save_report, +) +from cvat.apps.consensus.models import ConsensusSettings from cvat.apps.dataset_manager.bindings import import_dm_annotations from cvat.apps.dataset_manager.task import PatchAction, patch_job_data from cvat.apps.engine.models import Job, JobType, Task from cvat.apps.engine.serializers import RqIdSerializer -from cvat.apps.engine.utils import (define_dependent_job, get_rq_job_meta, - get_rq_lock_by_user, process_failed_job) +from cvat.apps.engine.utils import ( + define_dependent_job, + get_rq_job_meta, + get_rq_lock_by_user, + process_failed_job, +) from cvat.apps.quality_control.quality_reports import JobDataProvider @@ -35,7 +47,7 @@ def get_consensus_jobs(task_id: int) -> Dict[int, List[int]]: def get_annotations(job_id: int) -> dm.Dataset: - return JobDataProvider(job_id).dm_dataset + return JobDataProvider(job_id).dm_dataset # .get("08122008671") @transaction.atomic @@ -50,10 +62,16 @@ def _merge_consensus_jobs(task_id: int) -> None: ) ) + job_comparison_reports: Dict[int, ComparisonReport] = {} + for parent_job_id, job_ids in jobs.items(): - consensus_dataset = list(map(get_annotations, job_ids)) + consensus_job_data_providers = list(map(JobDataProvider, job_ids)) + consensus_datasets = [ + consensus_job_data_provider.dm_dataset + for consensus_job_data_provider in consensus_job_data_providers + ] - merged_dataset = merger(consensus_dataset) + merged_dataset = merger(consensus_datasets) # delete the existing annotations in the job patch_job_data(parent_job_id, None, PatchAction.DELETE) @@ -68,18 +86,23 @@ def _merge_consensus_jobs(task_id: int) -> None: import_dm_annotations(merged_dataset, parent_job.job_data) # updates the annotations in the job - patch_job_data( - parent_job_id, parent_job.job_data.data.serialize(), PatchAction.UPDATE + patch_job_data(parent_job_id, parent_job.job_data.data.serialize(), PatchAction.UPDATE) + + job_comparison_reports[parent_job_id] = generate_job_consensus_report( + consensus_settings=consensus_settings, + errors=merger.errors, + consensus_job_data_providers=consensus_job_data_providers, ) + task_report_data = generate_task_consensus_report(job_comparison_reports) + return save_report(task_id, jobs, task_report_data, job_comparison_reports) + def merge_task(task: Task, request) -> Response: queue_name = settings.CVAT_QUEUES.CONSENSUS.value queue = django_rq.get_queue(queue_name) # so a user doesn't create requests to merge same task multiple times - rq_id = request.data.get( - "rq_id", f"merge_consensus:task.id{task.id}-by-{request.user}" - ) + rq_id = request.data.get("rq_id", f"merge_consensus:task.id{task.id}-by-{request.user}") rq_job = queue.fetch_job(rq_id) user_id = request.user.id @@ -110,7 +133,7 @@ def merge_task(task: Task, request) -> Response: depends_on=define_dependent_job(queue, user_id), ) - return Response(status=status.HTTP_202_ACCEPTED) + return rq_id # serializer = RqIdSerializer(data={'rq_id': rq_id}) # serializer.is_valid(raise_exception=True) # return Response(serializer.data, status=status.HTTP_202_ACCEPTED) diff --git a/cvat/apps/consensus/migrations/0001_initial.py b/cvat/apps/consensus/migrations/0001_initial.py index 86c1c476f66..1cbbeec3baa 100644 --- a/cvat/apps/consensus/migrations/0001_initial.py +++ b/cvat/apps/consensus/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-06-27 11:47 +# Generated by Django 4.2.11 on 2024-07-05 18:04 import django.db.models.deletion from django.db import migrations, models @@ -9,7 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("engine", "0079_job_parent_job_id_task_consensus_jobs_per_segment"), + ("engine", "0080_alter_job_type"), ] operations = [ @@ -19,15 +19,12 @@ class Migration(migrations.Migration): ( "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" ), ), - ("agreement_score_threshold", models.FloatField()), - ("quorum", models.IntegerField()), - ("iou_threshold", models.FloatField()), + ("agreement_score_threshold", models.FloatField(default=0)), + ("quorum", models.IntegerField(default=0)), + ("iou_threshold", models.FloatField(default=0.5)), ( "task", models.ForeignKey( @@ -46,14 +43,22 @@ class Migration(migrations.Migration): ( "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" ), ), ("created_date", models.DateTimeField(auto_now_add=True)), + ("target_last_updated", models.DateTimeField()), ("data", models.JSONField()), + ( + "job", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="consensus_reports", + to="engine.job", + ), + ), ( "task", models.ForeignKey( @@ -66,4 +71,84 @@ class Migration(migrations.Migration): ), ], ), + migrations.CreateModel( + name="ConsensusConflict", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("frame", models.PositiveIntegerField()), + ( + "type", + models.CharField( + choices=[ + ("NO_MATCHING_ITEM", "NoMatchingItemError"), + ("FAILED_ATTRIBUTE_VOTING", "FailedAttrVotingError"), + ("NO_MATCHING_ANNOTATION", "NoMatchingAnnError"), + ("ANNOTATION_TOO_CLOSE", "AnnotationsTooCloseError"), + ("WRONG_GROUP", "WrongGroupError"), + ("FAILED_LABEL_VOTING", "FailedLabelVotingError"), + ], + max_length=32, + ), + ), + ( + "report", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="conflicts", + to="consensus.consensusreport", + ), + ), + ], + ), + migrations.CreateModel( + name="AnnotationId", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("obj_id", models.PositiveIntegerField()), + ("job_id", models.PositiveIntegerField()), + ( + "type", + models.CharField( + choices=[("tag", "TAG"), ("shape", "SHAPE"), ("track", "TRACK")], + max_length=32, + ), + ), + ( + "shape_type", + models.CharField( + choices=[ + ("rectangle", "RECTANGLE"), + ("polygon", "POLYGON"), + ("polyline", "POLYLINE"), + ("points", "POINTS"), + ("ellipse", "ELLIPSE"), + ("cuboid", "CUBOID"), + ("mask", "MASK"), + ("skeleton", "SKELETON"), + ], + default=None, + max_length=32, + null=True, + ), + ), + ( + "conflict", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="annotation_ids", + to="consensus.consensusconflict", + ), + ), + ], + ), ] diff --git a/cvat/apps/consensus/models.py b/cvat/apps/consensus/models.py index 4e67cf6b89d..c1b2e7d84b2 100644 --- a/cvat/apps/consensus/models.py +++ b/cvat/apps/consensus/models.py @@ -1,12 +1,57 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations # this allows forward references + from copy import deepcopy -from typing import Any +from enum import Enum +from typing import Any, Sequence +from datumaro.components.errors import ( + AnnotationsTooCloseError, + FailedAttrVotingError, + FailedLabelVotingError, + NoMatchingAnnError, + NoMatchingItemError, + WrongGroupError, +) +from django.core.exceptions import ValidationError from django.db import models from django.forms.models import model_to_dict from cvat.apps.engine.models import Job, ShapeType, Task +class ConsensusConflictType(str, Enum): + NoMatchingItemError = "NO_MATCHING_ITEM" + FailedAttrVotingError = "FAILED_ATTRIBUTE_VOTING" + NoMatchingAnnError = "NO_MATCHING_ANNOTATION" + AnnotationsTooCloseError = "ANNOTATION_TOO_CLOSE" + WrongGroupError = "WRONG_GROUP" + FailedLabelVotingError = "FAILED_LABEL_VOTING" + + def __str__(self) -> str: + return self.value + + @classmethod + def choices(cls): + return tuple((x.value, x.name) for x in cls) + + +class AnnotationType(str, Enum): + TAG = "tag" + SHAPE = "shape" + TRACK = "track" + + def __str__(self) -> str: + return self.value + + @classmethod + def choices(cls): + return tuple((x.value, x.name) for x in cls) + + class ConsensusSettings(models.Model): task = models.ForeignKey( Task, @@ -35,5 +80,81 @@ class ConsensusReport(models.Model): null=True, blank=True, ) + job = models.ForeignKey( + Job, + on_delete=models.CASCADE, + related_name="consensus_reports", + null=True, + blank=True, + ) + created_date = models.DateTimeField(auto_now_add=True) + target_last_updated = models.DateTimeField() + data = models.JSONField() + + conflicts: Sequence[ConsensusConflict] + + def _parse_report(self): + from cvat.apps.consensus.consensus_reports import ComparisonReport + + return ComparisonReport.from_json(self.data) + + @property + def summary(self): + report = self._parse_report() + return report.comparison_summary + + def get_task(self) -> Task: + if self.task is not None: + return self.task + else: + return self.job.segment.task + + def get_json_report(self) -> str: + return self.data + + def clean(self): + if not (self.job is not None) ^ (self.task is not None): + raise ValidationError("One of the 'job' and 'task' fields must be set") + + @property + def organization_id(self): + if task := self.get_task(): + return getattr(task.organization, "id", None) + return None + + +class ConsensusConflict(models.Model): + report = models.ForeignKey(ConsensusReport, on_delete=models.CASCADE, related_name="conflicts") + frame = models.PositiveIntegerField() + type = models.CharField(max_length=32, choices=ConsensusConflictType.choices()) + + annotation_ids: Sequence[AnnotationId] + + @property + def organization_id(self): + return self.report.organization_id + + +class AnnotationId(models.Model): + conflict = models.ForeignKey( + ConsensusConflict, on_delete=models.CASCADE, related_name="annotation_ids" + ) + + obj_id = models.PositiveIntegerField() + job_id = models.PositiveIntegerField() + type = models.CharField(max_length=32, choices=AnnotationType.choices()) + shape_type = models.CharField( + max_length=32, choices=ShapeType.choices(), null=True, default=None + ) + + def clean(self) -> None: + if self.type in [AnnotationType.SHAPE, AnnotationType.TRACK]: + if not self.shape_type: + raise ValidationError("Annotation kind must be specified") + elif self.type == AnnotationType.TAG: + if self.shape_type: + raise ValidationError("Annotation kind must be empty") + else: + raise ValidationError(f"Unexpected type value '{self.type}'") diff --git a/cvat/apps/consensus/new_intersect_merge.py b/cvat/apps/consensus/new_intersect_merge.py index 53dbf37e4c8..b6c01111a38 100644 --- a/cvat/apps/consensus/new_intersect_merge.py +++ b/cvat/apps/consensus/new_intersect_merge.py @@ -7,38 +7,52 @@ import logging as log from collections import OrderedDict from copy import deepcopy -from typing import (Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, - Type, Union) +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Type, Union from unittest import TestCase import attr import cv2 import numpy as np from attr import attrib, attrs -from datumaro.components.annotation import (Annotation, AnnotationType, Bbox, - Label, LabelCategories, - MaskCategories, PointsCategories) +from datumaro.components.annotation import ( + Annotation, + AnnotationType, + Bbox, + Label, + LabelCategories, + MaskCategories, + PointsCategories, +) from datumaro.components.cli_plugin import CliPlugin from datumaro.components.dataset import Dataset, DatasetItemStorage, IDataset -from datumaro.components.errors import (AnnotationsTooCloseError, - ConflictingCategoriesError, - DatasetMergeError, - FailedAttrVotingError, - FailedLabelVotingError, MediaTypeError, - MismatchingAttributesError, - MismatchingImageInfoError, - MismatchingMediaError, - MismatchingMediaPathError, - NoMatchingAnnError, - NoMatchingItemError, VideoMergeError, - WrongGroupError) +from datumaro.components.errors import ( + AnnotationsTooCloseError, + ConflictingCategoriesError, + DatasetMergeError, + FailedAttrVotingError, + FailedLabelVotingError, + MediaTypeError, + MismatchingAttributesError, + MismatchingImageInfoError, + MismatchingMediaError, + MismatchingMediaPathError, + NoMatchingAnnError, + NoMatchingItemError, + VideoMergeError, + WrongGroupError, +) from datumaro.components.extractor import CategoriesInfo, DatasetItem -from datumaro.components.media import (Image, MediaElement, MultiframeImage, - PointCloud, Video) +from datumaro.components.media import Image, MediaElement, MultiframeImage, PointCloud, Video from datumaro.util import filter_dict, find -from datumaro.util.annotation_util import (OKS, approximate_line, bbox_iou, - find_instances, max_bbox, mean_bbox, - segment_iou) +from datumaro.util.annotation_util import ( + OKS, + approximate_line, + bbox_iou, + find_instances, + max_bbox, + mean_bbox, + segment_iou, +) from datumaro.util.attrs_util import default_if_none, ensure_cls @@ -125,9 +139,7 @@ def merge(cls, *sources: IDataset) -> DatasetItemStorage: return items @classmethod - def _merge_items( - cls, existing_item: DatasetItem, current_item: DatasetItem - ) -> DatasetItem: + def _merge_items(cls, existing_item: DatasetItem, current_item: DatasetItem) -> DatasetItem: return existing_item.wrap( media=cls._merge_media(existing_item, current_item), attributes=cls._merge_attrs( @@ -135,15 +147,11 @@ def _merge_items( current_item.attributes, item_id=(existing_item.id, existing_item.subset), ), - annotations=cls._merge_anno( - existing_item.annotations, current_item.annotations - ), + annotations=cls._merge_anno(existing_item.annotations, current_item.annotations), ) @staticmethod - def _merge_attrs( - a: Dict[str, Any], b: Dict[str, Any], item_id: Tuple[str, str] - ) -> Dict: + def _merge_attrs(a: Dict[str, Any], b: Dict[str, Any], item_id: Tuple[str, str]) -> Dict: merged = {} for name in a.keys() | b.keys(): @@ -186,9 +194,7 @@ def _merge_media( elif (not item_a.media or isinstance(item_a.media, MediaElement)) and ( not item_b.media or isinstance(item_b.media, MediaElement) ): - if isinstance(item_a.media, MediaElement) and isinstance( - item_b.media, MediaElement - ): + if isinstance(item_a.media, MediaElement) and isinstance(item_b.media, MediaElement): if ( item_a.media.path and item_b.media.path @@ -208,9 +214,7 @@ def _merge_media( else: media = item_b.media else: - raise MismatchingMediaError( - (item_a.id, item_a.subset), item_a.media, item_b.media - ) + raise MismatchingMediaError((item_a.id, item_a.subset), item_a.media, item_b.media) return media @staticmethod @@ -281,14 +285,8 @@ def _merge_images(item_a: DatasetItem, item_b: DatasetItem) -> Image: def _merge_point_clouds(item_a: DatasetItem, item_b: DatasetItem) -> PointCloud: media = None - if isinstance(item_a.media, PointCloud) and isinstance( - item_b.media, PointCloud - ): - if ( - item_a.media.path - and item_b.media.path - and item_a.media.path != item_b.media.path - ): + if isinstance(item_a.media, PointCloud) and isinstance(item_b.media, PointCloud): + if item_a.media.path and item_b.media.path and item_a.media.path != item_b.media.path: raise MismatchingMediaPathError( (item_a.id, item_a.subset), item_a.media.path, item_b.media.path ) @@ -337,19 +335,11 @@ def _merge_videos(item_a: DatasetItem, item_b: DatasetItem) -> Video: return media @staticmethod - def _merge_multiframe_images( - item_a: DatasetItem, item_b: DatasetItem - ) -> MultiframeImage: + def _merge_multiframe_images(item_a: DatasetItem, item_b: DatasetItem) -> MultiframeImage: media = None - if isinstance(item_a.media, MultiframeImage) and isinstance( - item_b.media, MultiframeImage - ): - if ( - item_a.media.path - and item_b.media.path - and item_a.media.path != item_b.media.path - ): + if isinstance(item_a.media, MultiframeImage) and isinstance(item_b.media, MultiframeImage): + if item_a.media.path and item_b.media.path and item_a.media.path != item_b.media.path: raise MismatchingMediaPathError( (item_a.id, item_a.subset), item_a.media.path, item_b.media.path ) @@ -377,9 +367,7 @@ def _merge_multiframe_images( return media @staticmethod - def _merge_anno( - a: Iterable[Annotation], b: Iterable[Annotation] - ) -> List[Annotation]: + def _merge_anno(a: Iterable[Annotation], b: Iterable[Annotation]) -> List[Annotation]: return merge_annotations_equal(a, b) @staticmethod @@ -481,16 +469,13 @@ def merge_items(self, items): self._ann_map.update({id(a): (a, id(item)) for a in item.annotations}) sources.append(item.annotations) log.debug( - "Merging item %s: source annotations %s" - % (self._item_id, list(map(len, sources))) + "Merging item %s: source annotations %s" % (self._item_id, list(map(len, sources))) ) annotations = self.merge_annotations(sources) annotations = [ - a - for a in annotations - if self.conf.output_conf_thresh <= a.attributes.get("score", 1) + a for a in annotations if self.conf.output_conf_thresh <= a.attributes.get("score", 1) ] return self._item.wrap(annotations=annotations) @@ -513,16 +498,12 @@ def merge_annotations(self, sources): for merged_ann, cluster in zip(merged_clusters, clusters): attributes = self._find_cluster_attrs(cluster, merged_ann) attributes = { - k: v - for k, v in attributes.items() - if k not in self.conf.ignored_attributes + k: v for k, v in attributes.items() if k not in self.conf.ignored_attributes } attributes.update(merged_ann.attributes) merged_ann.attributes = attributes - new_group_id = find( - enumerate(group_map), lambda e: id(cluster) in e[1][0] - ) + new_group_id = find(enumerate(group_map), lambda e: id(cluster) in e[1][0]) if new_group_id is None: new_group_id = 0 else: @@ -624,8 +605,7 @@ def _merge_point_categories(self, sources, label_cat): if dst_cat != src_cat: raise ConflictingCategoriesError( "Can't merge point category for label " - "%s (from #%s): %s vs. %s" - % (src_label, src_id, src_cat, dst_cat), + "%s (from #%s): %s vs. %s" % (src_label, src_id, src_cat, dst_cat), sources=list(range(src_id)), ) else: @@ -656,8 +636,7 @@ def _merge_mask_categories(self, sources, label_cat): if dst_cat != src_cat: raise ConflictingCategoriesError( "Can't merge mask category for label " - "%s (from #%s): %s vs. %s" - % (src_label, src_id, src_cat, dst_cat), + "%s (from #%s): %s vs. %s" % (src_label, src_id, src_cat, dst_cat), sources=list(range(src_id)), ) else: @@ -755,9 +734,7 @@ def _for_type(t, **kwargs): for ann in inst: instance_map[id(ann)] = [inst, inst_bbox] - self._mergers = { - t: _for_type(t, instance_map=instance_map) for t in AnnotationType - } + self._mergers = {t: _for_type(t, instance_map=instance_map) for t in AnnotationType} def _match_ann_type(self, t, sources): return self._mergers[t].match_annotations(sources) @@ -849,12 +826,8 @@ def _has_item(s): return False return True - missing_sources = set(self._dataset_map) - set( - self.get_ann_source(id(a)) for a in cluster - ) - missing_sources = [ - self._dataset_map[s][1] for s in missing_sources if _has_item(s) - ] + missing_sources = set(self._dataset_map) - set(self.get_ann_source(id(a)) for a in cluster) + missing_sources = [self._dataset_map[s][1] for s in missing_sources if _has_item(s)] if missing_sources: self.add_item_error(NoMatchingAnnError, cluster[0], sources=missing_sources) @@ -878,9 +851,7 @@ def _check_group(group_labels, group): real_miss = check_group - common - optional extra = group_labels - check_group if common and (extra or real_miss): - self.add_item_error( - WrongGroupError, group_labels, check_group, group - ) + self.add_item_error(WrongGroupError, group_labels, check_group, group) break groups = find_instances(annotations) @@ -916,10 +887,7 @@ def _get_src_label_name(self, ann, label_id): item_id = self._ann_map[id(ann)][1] dataset_id = self._item_map[item_id][1] return ( - self._dataset_map[dataset_id][0] - .categories()[AnnotationType.label] - .items[label_id] - .name + self._dataset_map[dataset_id][0].categories()[AnnotationType.label].items[label_id].name ) def _get_any_label_name(self, ann, label_id): @@ -940,10 +908,7 @@ def _check_groups_definition(self): "label '%s', available labels %s" % ( label, - [ - i.name - for i in self._categories[AnnotationType.label].items - ], + [i.name for i in self._categories[AnnotationType.label].items], ) ) @@ -1171,13 +1136,10 @@ def merge_clusters(self, clusters): sources = set( self.get_ann_source(id(a)) for a in clusters[0] - if label - not in [self._context._get_src_label_name(l, l.label) for l in a] + if label not in [self._context._get_src_label_name(l, l.label) for l in a] ) sources = [self._context._dataset_map[s][1] for s in sources] - self._context.add_item_error( - FailedLabelVotingError, votes, sources=sources - ) + self._context.add_item_error(FailedLabelVotingError, votes, sources=sources) continue merged.append( @@ -1225,9 +1187,7 @@ def _merge_cluster_shape_mean_box_nearest(cluster): def merge_cluster_shape(self, cluster): shape = self._merge_cluster_shape_mean_box_nearest(cluster) - shape_score = sum(max(0, self.distance(shape, s)) for s in cluster) / len( - cluster - ) + shape_score = sum(max(0, self.distance(shape, s)) for s in cluster) / len(cluster) return shape, shape_score def merge_cluster(self, cluster): @@ -1241,9 +1201,7 @@ def merge_cluster(self, cluster): # return None shape.z_order = max(cluster, key=lambda a: a.z_order).z_order shape.label = label - shape.attributes["score"] = ( - label_score * shape_score if label is not None else shape_score - ) + shape.attributes["score"] = label_score * shape_score if label is not None else shape_score return shape @@ -1293,9 +1251,7 @@ def merge_cluster(self, cluster): shape, shape_score = self.merge_cluster_shape(cluster) shape.label = label - shape.attributes["score"] = ( - label_score * shape_score if label is not None else shape_score - ) + shape.attributes["score"] = label_score * shape_score if label is not None else shape_score return shape @@ -1595,9 +1551,7 @@ def get_label(ann): } label_stat = { "count": 0, - "distribution": { - l.name: [0, 0] for l in labels.items - }, # label -> (count, total%) + "distribution": {l.name: [0, 0] for l in labels.items}, # label -> (count, total%) "attributes": {}, } stats["annotations"]["labels"] = label_stat @@ -1605,9 +1559,7 @@ def get_label(ann): "avg. area": 0, "area distribution": [], # a histogram with 10 bins # (min, min+10%), ..., (min+90%, max) -> (count, total%) - "pixel distribution": { - l.name: [0, 0] for l in labels.items - }, # label -> (count, total%) + "pixel distribution": {l.name: [0, 0] for l in labels.items}, # label -> (count, total%) } stats["annotations"]["segments"] = segm_stat segm_areas = [] @@ -1646,18 +1598,14 @@ def get_label(ann): "track_id", }: continue - attrs_stat = label_stat["attributes"].setdefault( - name, deepcopy(attr_template) - ) + attrs_stat = label_stat["attributes"].setdefault(name, deepcopy(attr_template)) attrs_stat["count"] += 1 attrs_stat["values present"].add(str(value)) attrs_stat["distribution"].setdefault(str(value), [0, 0])[0] += 1 stats["images count"] = len(dataset) - stats["annotations count"] = sum( - t["count"] for t in stats["annotations by type"].values() - ) + stats["annotations count"] = sum(t["count"] for t in stats["annotations by type"].values()) stats["unannotated images count"] = len(stats["unannotated images"]) for label_info in label_stat["distribution"].values(): @@ -1723,12 +1671,8 @@ def _get_ann_type(t, item): return [a for a in item.annotations if a.type == t] def match_labels(self, item_a, item_b): - a_labels = set( - a.label for a in self._get_ann_type(AnnotationType.label, item_a) - ) - b_labels = set( - a.label for a in self._get_ann_type(AnnotationType.label, item_b) - ) + a_labels = set(a.label for a in self._get_ann_type(AnnotationType.label, item_a)) + b_labels = set(a.label for a in self._get_ann_type(AnnotationType.label, item_b)) matches = a_labels & b_labels a_unmatched = a_labels - b_labels @@ -1868,9 +1812,7 @@ class ExactComparator: match_images: bool = attrib(kw_only=True, default=False) ignored_fields = attrib(kw_only=True, factory=set, validator=default_if_none(set)) ignored_attrs = attrib(kw_only=True, factory=set, validator=default_if_none(set)) - ignored_item_attrs = attrib( - kw_only=True, factory=set, validator=default_if_none(set) - ) + ignored_item_attrs = attrib(kw_only=True, factory=set, validator=default_if_none(set)) _test: TestCase = attrib(init=False) errors: list = attrib(init=False) @@ -1890,9 +1832,7 @@ def _compare_categories(self, a, b): errors = self.errors try: - test.assertEqual( - sorted(a, key=lambda t: t.value), sorted(b, key=lambda t: t.value) - ) + test.assertEqual(sorted(a, key=lambda t: t.value), sorted(b, key=lambda t: t.value)) except AssertionError as e: errors.append({"type": "categories", "message": str(e)}) @@ -1951,18 +1891,14 @@ def _compare_items(self, item_a, item_b): filter_dict(item_b.attributes, self.ignored_item_attrs), ) except AssertionError as e: - errors.append( - {"type": "item_attr", "a_item": a_id, "b_item": b_id, "message": str(e)} - ) + errors.append({"type": "item_attr", "a_item": a_id, "b_item": b_id, "message": str(e)}) b_annotations = item_b.annotations[:] for ann_a in item_a.annotations: ann_b_candidates = [x for x in item_b.annotations if x.type == ann_a.type] ann_b = find( - enumerate( - self._compare_annotations(ann_a, x) for x in ann_b_candidates - ), + enumerate(self._compare_annotations(ann_a, x) for x in ann_b_candidates), lambda x: x[1], ) if ann_b is None: @@ -1978,9 +1914,7 @@ def _compare_items(self, item_a, item_b): ann_b = ann_b_candidates[ann_b[0]] b_annotations.remove(ann_b) # avoid repeats - matched.append( - {"a_item": a_id, "b_item": b_id, "a": str(ann_a), "b": str(ann_b)} - ) + matched.append({"a_item": a_id, "b_item": b_id, "a": str(ann_a), "b": str(ann_b)}) for ann_b in b_annotations: unmatched.append({"item": b_id, "source": "b", "ann": str(ann_b)}) @@ -1998,9 +1932,7 @@ def compare_datasets(self, a, b): matches, a_unmatched, b_unmatched = self._match_items(a, b) - if a.categories().get(AnnotationType.label) != b.categories().get( - AnnotationType.label - ): + if a.categories().get(AnnotationType.label) != b.categories().get(AnnotationType.label): return matched, unmatched, a_unmatched, b_unmatched, errors _dist = lambda s: len(s[1]) + len(s[2]) diff --git a/cvat/apps/consensus/permissions.py b/cvat/apps/consensus/permissions.py index 76cbb75abc0..7a64747a05a 100644 --- a/cvat/apps/consensus/permissions.py +++ b/cvat/apps/consensus/permissions.py @@ -10,10 +10,159 @@ from cvat.apps.engine.models import Task from cvat.apps.engine.permissions import TaskPermission -from cvat.apps.iam.permissions import (OpenPolicyAgentPermission, StrEnum, - get_iam_context) +from cvat.apps.iam.permissions import OpenPolicyAgentPermission, StrEnum, get_iam_context -from .models import ConsensusSettings +from .models import ConsensusConflict, ConsensusReport, ConsensusSettings + + +class ConsensusReportPermission(OpenPolicyAgentPermission): + obj: Optional[ConsensusReport] + job_owner_id: Optional[int] + + class Scopes(StrEnum): + LIST = "list" + CREATE = "create" + VIEW = "view" + VIEW_STATUS = "view:status" + + @classmethod + def create_scope_check_status(cls, request, job_owner_id: int, iam_context=None): + if not iam_context and request: + iam_context = get_iam_context(request, None) + return cls(**iam_context, scope="view:status", job_owner_id=job_owner_id) + + @classmethod + def create_scope_view(cls, request, report: Union[int, ConsensusReport], iam_context=None): + if isinstance(report, int): + try: + report = ConsensusReport.objects.get(id=report) + except ConsensusReport.DoesNotExist as ex: + raise ValidationError(str(ex)) + + # Access rights are the same as in the owning task + # This component doesn't define its own rules in this case + return TaskPermission.create_scope_view( + request, + task=report.get_task(), + iam_context=iam_context, + ) + + @classmethod + def create(cls, request, view, obj, iam_context): + Scopes = __class__.Scopes + + permissions = [] + if view.basename == "consensus_reports": + for scope in cls.get_scopes(request, view, obj): + if scope == Scopes.VIEW: + permissions.append(cls.create_scope_view(request, obj, iam_context=iam_context)) + elif scope == Scopes.LIST and isinstance(obj, Task): + permissions.append(TaskPermission.create_scope_view(request, task=obj)) + elif scope == Scopes.CREATE: + task_id = request.data.get("task_id") + if task_id is not None: + permissions.append(TaskPermission.create_scope_view(request, task_id)) + + permissions.append(cls.create_base_perm(request, view, scope, iam_context, obj)) + else: + permissions.append(cls.create_base_perm(request, view, scope, iam_context, obj)) + + return permissions + + def __init__(self, **kwargs): + if "job_owner_id" in kwargs: + self.job_owner_id = int(kwargs.pop("job_owner_id")) + + super().__init__(**kwargs) + self.url = settings.IAM_OPA_DATA_URL + "/consensus_reports/allow" + + @staticmethod + def get_scopes(request, view, obj): + Scopes = __class__.Scopes + return [ + { + "list": Scopes.LIST, + "create": Scopes.CREATE, + "retrieve": Scopes.VIEW, + "data": Scopes.VIEW, + }.get(view.action, None) + ] + + def get_resource(self): + data = None + + if self.obj: + task = self.obj.get_task() + if task.project: + organization = task.project.organization + else: + organization = task.organization + + data = { + "id": self.obj.id, + "organization": {"id": getattr(organization, "id", None)}, + "task": ( + { + "owner": {"id": getattr(task.owner, "id", None)}, + "assignee": {"id": getattr(task.assignee, "id", None)}, + } + if task + else None + ), + "project": ( + { + "owner": {"id": getattr(task.project.owner, "id", None)}, + "assignee": {"id": getattr(task.project.assignee, "id", None)}, + } + if task.project + else None + ), + } + elif self.scope == self.Scopes.VIEW_STATUS: + data = {"owner": self.job_owner_id} + + return data + + +class ConsensusConflictPermission(OpenPolicyAgentPermission): + obj: Optional[ConsensusConflict] + + class Scopes(StrEnum): + LIST = "list" + + @classmethod + def create(cls, request, view, obj, iam_context): + permissions = [] + if view.basename == "conflicts": + for scope in cls.get_scopes(request, view, obj): + if scope == cls.Scopes.LIST and isinstance(obj, ConsensusReport): + permissions.append( + ConsensusReportPermission.create_scope_view( + request, + obj, + iam_context=iam_context, + ) + ) + else: + permissions.append(cls.create_base_perm(request, view, scope, iam_context, obj)) + + return permissions + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.url = settings.IAM_OPA_DATA_URL + "/conflicts/allow" + + @staticmethod + def get_scopes(request, view, obj): + Scopes = __class__.Scopes + return [ + { + "list": Scopes.LIST, + }.get(view.action, None) + ] + + def get_resource(self): + return None class ConsensusSettingPermission(OpenPolicyAgentPermission): @@ -64,9 +213,7 @@ def create(cls, request, view, obj, iam_context): permissions.append(cls.create_scope_list(request, iam_context)) else: - permissions.append( - cls.create_base_perm(request, view, scope, iam_context, obj) - ) + permissions.append(cls.create_base_perm(request, view, scope, iam_context, obj)) return permissions diff --git a/cvat/apps/consensus/rules/conflicts.rego b/cvat/apps/consensus/rules/conflicts.rego new file mode 100644 index 00000000000..f8e570b5882 --- /dev/null +++ b/cvat/apps/consensus/rules/conflicts.rego @@ -0,0 +1,118 @@ +package conflicts + +import rego.v1 + +import data.utils +import data.organizations + +# input: { +# "scope": <"list"> 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 := [ + {"report__job__segment__task__organization": org.id}, + {"report__job__segment__task__project__organization": org.id}, "|", + {"report__task__organization": org.id}, "|", + {"report__task__project__organization": org.id}, "|", + ] +} else := qobject if { + utils.is_sandbox + user := input.auth.user + qobject := [ + {"report__job__segment__task__owner_id": user.id}, + {"report__job__segment__task__assignee_id": user.id}, "|", + {"report__job__segment__task__project__owner_id": user.id}, "|", + {"report__job__segment__task__project__assignee_id": user.id}, "|", + {"report__task__owner_id": user.id}, "|", + {"report__task__assignee_id": user.id}, "|", + {"report__task__project__owner_id": user.id}, "|", + {"report__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 := [ + {"report__job__segment__task__organization": org.id}, + {"report__job__segment__task__project__organization": org.id}, "|", + {"report__task__organization": org.id}, "|", + {"report__task__project__organization": org.id}, "|", + ] +} else := qobject if { + organizations.has_perm(organizations.WORKER) + user := input.auth.user + org := input.auth.organization + qobject := [ + {"report__job__segment__task__organization": org.id}, + {"report__job__segment__task__project__organization": org.id}, "|", + {"report__task__organization": org.id}, "|", + {"report__task__project__organization": org.id}, "|", + + {"report__job__segment__task__owner_id": user.id}, + {"report__job__segment__task__assignee_id": user.id}, "|", + {"report__job__segment__task__project__owner_id": user.id}, "|", + {"report__job__segment__task__project__assignee_id": user.id}, "|", + {"report__task__owner_id": user.id}, "|", + {"report__task__assignee_id": user.id}, "|", + {"report__task__project__owner_id": user.id}, "|", + {"report__task__project__assignee_id": user.id}, "|", + + "&" + ] +} diff --git a/cvat/apps/consensus/rules/consensus_reports.rego b/cvat/apps/consensus/rules/consensus_reports.rego new file mode 100644 index 00000000000..c37b70205a2 --- /dev/null +++ b/cvat/apps/consensus/rules/consensus_reports.rego @@ -0,0 +1,118 @@ +package consensus_reports + +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/consensus/serializers.py b/cvat/apps/consensus/serializers.py index 41109620ed8..29e490f8541 100644 --- a/cvat/apps/consensus/serializers.py +++ b/cvat/apps/consensus/serializers.py @@ -1,15 +1,69 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + import textwrap from django.db import IntegrityError, models, transaction from rest_framework import serializers -from cvat.apps.consensus.models import ConsensusSettings +from cvat.apps.consensus import models +from cvat.apps.consensus.models import AnnotationId from cvat.apps.engine.models import Task +class ConsensusAnnotationIdSerializer(serializers.ModelSerializer): + class Meta: + model = AnnotationId + fields = ("obj_id", "job_id", "type", "shape_type") + read_only_fields = fields + + +class ConsensusConflictSerializer(serializers.ModelSerializer): + annotation_ids = ConsensusAnnotationIdSerializer(many=True) + + class Meta: + model = models.ConsensusConflict + fields = ("id", "frame", "type", "annotation_ids", "report_id") + read_only_fields = fields + + +class ConsensusReportSummarySerializer(serializers.Serializer): + frame_count = serializers.IntegerField() + conflict_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") + + +class ConsensusReportSerializer(serializers.ModelSerializer): + summary = ConsensusReportSummarySerializer() + + class Meta: + model = models.ConsensusReport + fields = ( + "id", + "job_id", + "task_id", + "summary", + "created_date", + "target_last_updated", + ) + read_only_fields = fields + + +class ConsensusReportCreateSerializer(serializers.Serializer): + task_id = serializers.IntegerField(write_only=True) + + class ConsensusSettingsSerializer(serializers.ModelSerializer): class Meta: - model = ConsensusSettings + model = models.ConsensusSettings fields = ( "id", "task_id", @@ -41,13 +95,10 @@ def validate(self, attrs): for k, v in attrs.items(): if k.endswith("_threshold"): if not 0 <= v <= 1: - raise serializers.ValidationError( - f"{k} must be in the range [0; 1]" - ) + raise serializers.ValidationError(f"{k} must be in the range [0; 1]") elif k == "quorum": + # since we have constrained max. consensus jobs per normal job to 10 if not 0 <= v <= 10: - raise serializers.ValidationError( - f"{k} must be in the range [0; 10]" - ) + raise serializers.ValidationError(f"{k} must be in the range [0; 10]") return super().validate(attrs) diff --git a/cvat/apps/consensus/urls.py b/cvat/apps/consensus/urls.py index 6ff55a02cca..eedd62c7a70 100644 --- a/cvat/apps/consensus/urls.py +++ b/cvat/apps/consensus/urls.py @@ -8,10 +8,9 @@ from cvat.apps.consensus import views router = routers.DefaultRouter(trailing_slash=False) -# router.register("reports", views.ConsensusReportViewSet, basename="consensus_reports") -router.register( - "settings", views.ConsensusSettingsViewSet, basename="consensus_settings" -) +router.register("reports", views.ConsensusReportViewSet, basename="consensus_reports") +router.register("settings", views.ConsensusSettingsViewSet, basename="consensus_settings") +router.register("conflicts", views.ConsensusConflictsViewSet, basename="conflicts") urlpatterns = [ # entry point for API diff --git a/cvat/apps/consensus/views.py b/cvat/apps/consensus/views.py index 622c52586dc..e37909d3637 100644 --- a/cvat/apps/consensus/views.py +++ b/cvat/apps/consensus/views.py @@ -4,34 +4,40 @@ import textwrap +import django_rq +from django.conf import settings 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 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.consensus.models import ConsensusSettings -from cvat.apps.consensus.permissions import ConsensusSettingPermission -from cvat.apps.consensus.serializers import ConsensusSettingsSerializer +from cvat.apps.consensus.consensus_reports import prepare_report_for_downloading +from cvat.apps.consensus.merge_consensus_jobs import merge_task +from cvat.apps.consensus.models import ConsensusConflict, ConsensusReport, ConsensusSettings +from cvat.apps.consensus.permissions import ( + ConsensusConflictPermission, + ConsensusReportPermission, + ConsensusSettingPermission, +) +from cvat.apps.consensus.serializers import ( + ConsensusConflictSerializer, + ConsensusReportCreateSerializer, + ConsensusReportSerializer, + ConsensusSettingsSerializer, +) 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.quality_control import quality_reports as qc -from cvat.apps.quality_control.models import (AnnotationConflict, - QualityReport, - QualityReportTarget, - QualitySettings) -from cvat.apps.quality_control.permissions import ( - AnnotationConflictPermission, QualityReportPermission, - QualitySettingPermission) -from cvat.apps.quality_control.serializers import ( - AnnotationConflictSerializer, QualityReportCreateSerializer, - QualityReportSerializer, QualitySettingsSerializer) """ engine> views.py> TaskViewSet @@ -54,19 +60,275 @@ @extend_schema(tags=["consensus"]) @extend_schema_view( list=extend_schema( - summary="List quality settings instances", + summary="List annotation conflicts in a consensus report", + parameters=[ + # These filters are implemented differently from others + OpenApiParameter( + "report_id", + type=OpenApiTypes.INT, + description="A simple equality filter for report id", + ), + ], + responses={ + "200": ConsensusConflictSerializer(many=True), + }, + ), +) +class ConsensusConflictsViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): + queryset = ( + ConsensusConflict.objects.select_related( + "report", + "report__parent", + "report__job", + "report__job__segment", + "report__job__segment__task", + "report__job__segment__task__organization", + "report__task", + "report__task__organization", + ) + .prefetch_related( + "annotation_ids", + ) + .all() + ) + + iam_organization_field = [ + "report__job__segment__task__organization", + "report__task__organization", + ] + + search_fields = [] + filter_fields = list(search_fields) + ["id", "frame", "type", "job_id", "task_id"] + simple_filters = set(filter_fields) - {"id"} + lookup_fields = { + "job_id": "report__job__id", + "task_id": "report__job__segment__task__id", # task reports do not contain own conflicts + } + ordering_fields = list(filter_fields) + ordering = "-id" + serializer_class = ConsensusConflictSerializer + + def get_queryset(self): + queryset = super().get_queryset() + + if self.action == "list": + if report_id := self.request.query_params.get("report_id", None): + # NOTE: This filter is too complex to be implemented by other means, + # it has a dependency on the report type + try: + report = ConsensusReport.objects.get(id=report_id) + except ConsensusReport.DoesNotExist as ex: + raise NotFound(f"Report {report_id} does not exist") from ex + + self.check_object_permissions(self.request, report) + + queryset = queryset.filter(report=report) + else: + perm = ConsensusConflictPermission.create_scope_list(self.request) + queryset = perm.filter(queryset) + + return queryset + + +@extend_schema(tags=["consensus"]) +@extend_schema_view( + retrieve=extend_schema( + operation_id="consensus_retrieve_report", # the default produces the plural + summary="Get consensus report details", + responses={ + "200": ConsensusReportSerializer, + }, + ), + list=extend_schema( + summary="List consensus reports", + parameters=[ + # These filters are implemented differently from others + OpenApiParameter( + "task_id", type=OpenApiTypes.INT, description="A simple equality filter for task id" + ), + ], + responses={ + "200": ConsensusReportSerializer(many=True), + }, + ), +) +class ConsensusReportViewSet( + viewsets.GenericViewSet, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, +): + queryset = ConsensusReport.objects.prefetch_related( + "job", + "job__segment", + "job__segment__task", + "job__segment__task__organization", + "task", + "task__organization", + ).all() + + iam_organization_field = ["job__segment__task__organization", "task__organization"] + + search_fields = [] + filter_fields = list(search_fields) + [ + "id", + "job_id", + "created_date", + "target_last_updated", + ] + simple_filters = list(set(filter_fields) - {"id", "created_date", "target_last_updated"}) + ordering_fields = list(filter_fields) + ordering = "id" + + def get_serializer_class(self): + # a separate method is required for drf-spectacular to work + return ConsensusReportSerializer + + def get_queryset(self): + queryset = super().get_queryset() + + if self.action == "list": + if task_id := self.request.query_params.get("task_id", None): + # NOTE: This filter is too complex to be implemented by other means + try: + task = Task.objects.get(id=task_id) + except Task.DoesNotExist as ex: + raise NotFound(f"Task {task_id} does not exist") from ex + + self.check_object_permissions(self.request, task) + + queryset = queryset.filter( + Q(job__segment__task__id=task_id) | Q(task__id=task_id) + ).distinct() + else: + perm = ConsensusReportPermission.create_scope_list(self.request) + queryset = perm.filter(queryset) + + return queryset + + CREATE_REPORT_RQ_ID_PARAMETER = "rq_id" + + @extend_schema( + operation_id="consensus_create_report", + summary="Create a consensus report", + parameters=[ + OpenApiParameter( + CREATE_REPORT_RQ_ID_PARAMETER, + type=str, + description=textwrap.dedent( + """\ + The report creation request id. Can be specified to check the report + creation status. + """ + ), + ) + ], + request=ConsensusReportCreateSerializer(required=False), + responses={ + "201": ConsensusReportSerializer, + "202": OpenApiResponse( + RqIdSerializer, + description=textwrap.dedent( + """\ + A consensus report request has been enqueued, the request id is returned. + The request status can be checked at this endpoint by passing the {} + as the query parameter. If the request id is specified, this response + means the consensus report request is queued or is being processed. + """.format( + CREATE_REPORT_RQ_ID_PARAMETER + ) + ), + ), + "400": OpenApiResponse( + description="Invalid or failed request, check the response data for details" + ), + }, + ) + def create(self, request, *args, **kwargs): + self.check_permissions(request) + input_serializer = ConsensusReportCreateSerializer(data=request.data) + input_serializer.is_valid(raise_exception=True) + task_id = input_serializer.validated_data["task_id"] + + queue_name = settings.CVAT_QUEUES.CONSENSUS.value + queue = django_rq.get_queue(queue_name) + # rq_id = request.query_params.get(self.CREATE_REPORT_RQ_ID_PARAMETER, None) + rq_id = request.data.get("rq_id", f"merge_consensus:task.id{task_id}-by-{request.user}") + rq_job = queue.fetch_job(rq_id) + + if rq_job is None: + try: + task = Task.objects.get(pk=task_id) + except Task.DoesNotExist as ex: + raise NotFound(f"Task {task_id} does not exist") from ex + + try: + rq_id = merge_task(task, request) + serializer = RqIdSerializer({"rq_id": rq_id}) + return Response(serializer.data, status=status.HTTP_202_ACCEPTED) + except Exception as ex: + raise ValidationError(str(ex)) + + else: + if ( + not rq_job + or not ConsensusReportPermission.create_scope_check_status( + request, job_owner_id=rq_job.meta["user"]["id"] + ) + .check_access() + .allow + ): + # We should not provide job existence information to unauthorized users + raise NotFound("Unknown request id") + + if rq_job.is_failed: + message = str(rq_job.exc_info) + rq_job.delete() + raise ValidationError(message) + elif rq_job.is_queued or rq_job.is_started: + return Response(status=status.HTTP_202_ACCEPTED) + elif rq_job.is_finished: + return_value = rq_job.return_value() + rq_job.delete() + if not return_value: + raise ValidationError("No report has been computed") + + report = self.get_queryset().get(pk=return_value) + report_serializer = ConsensusReportSerializer(instance=report) + return Response( + data=report_serializer.data, + status=status.HTTP_201_CREATED, + headers=self.get_success_headers(report_serializer.data), + ) + + @extend_schema( + operation_id="consensus_retrieve_report_data", + summary="Get consensus report contents", + responses={"200": OpenApiTypes.OBJECT}, + ) + @action(detail=True, methods=["GET"], url_path="data", serializer_class=None) + def data(self, request, pk): + report = self.get_object() # check permissions + json_report = prepare_report_for_downloading(report, host=get_server_url(request)) + return HttpResponse(json_report.encode()) + + +@extend_schema(tags=["consensus"]) +@extend_schema_view( + list=extend_schema( + summary="List consensus settings instances", responses={ "200": ConsensusSettingsSerializer(many=True), }, ), retrieve=extend_schema( - summary="Get quality settings instance details", + summary="Get consensus settings instance details", parameters=[ OpenApiParameter( "id", type=OpenApiTypes.INT, location="path", - description="An id of a quality settings instance", + description="An id of a consensus settings instance", ) ], responses={ @@ -74,13 +336,13 @@ }, ), partial_update=extend_schema( - summary="Update a quality settings instance", + summary="Update a consensus settings instance", parameters=[ OpenApiParameter( "id", type=OpenApiTypes.INT, location="path", - description="An id of a quality settings instance", + description="An id of a consensus settings instance", ) ], request=ConsensusSettingsSerializer(partial=True), @@ -95,9 +357,7 @@ class ConsensusSettingsViewSet( mixins.RetrieveModelMixin, PartialUpdateModelMixin, ): - queryset = ConsensusSettings.objects.select_related( - "task", "task__organization" - ).all() + queryset = ConsensusSettings.objects.select_related("task", "task__organization").all() iam_organization_field = "task__organization" From e538038294517bd9d35f51e7dfa5543994eb6373 Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 6 Jul 2024 00:00:19 +0530 Subject: [PATCH 071/301] updated `schema.yml` --- cvat/schema.yml | 707 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 689 insertions(+), 18 deletions(-) diff --git a/cvat/schema.yml b/cvat/schema.yml index a18a7c0daaf..1911dbc3d45 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1049,6 +1049,414 @@ paths: responses: '204': description: The comment has been deleted + /api/consensus/conflicts: + get: + operationId: consensus_list_conflicts + summary: List annotation conflicts in a consensus report + parameters: + - name: X-Organization + in: header + description: Organization unique slug + schema: + type: string + - name: filter + required: false + in: query + description: |2- + + JSON Logic filter. This filter can be used to perform complex filtering by grouping rules. + + For example, using such a filter you can get all resources created by you: + + - {"and":[{"==":[{"var":"owner"},""]}]} + + Details about the syntax used can be found at the link: https://jsonlogic.com/ + + Available filter_fields: ['id', 'frame', 'type', 'job_id', 'task_id']. + schema: + type: string + - name: frame + in: query + description: A simple equality filter for the frame field + schema: + type: integer + - name: job_id + in: query + description: A simple equality filter for the job_id field + schema: + type: integer + - name: org + in: query + description: Organization unique slug + schema: + type: string + - name: org_id + in: query + description: Organization identifier + schema: + type: integer + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: report_id + schema: + type: integer + description: A simple equality filter for report id + - name: sort + required: false + in: query + description: 'Which field to use when ordering the results. Available ordering_fields: + [''id'', ''frame'', ''type'', ''job_id'', ''task_id'']' + schema: + type: string + - name: task_id + in: query + description: A simple equality filter for the task_id field + schema: + type: integer + - name: type + in: query + description: A simple equality filter for the type field + schema: + type: string + enum: + - NO_MATCHING_ITEM + - FAILED_ATTRIBUTE_VOTING + - NO_MATCHING_ANNOTATION + - ANNOTATION_TOO_CLOSE + - WRONG_GROUP + - FAILED_LABEL_VOTING + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/PaginatedConsensusConflictList' + description: '' + /api/consensus/reports: + get: + operationId: consensus_list_reports + summary: List consensus reports + parameters: + - name: X-Organization + in: header + description: Organization unique slug + schema: + type: string + - name: filter + required: false + in: query + description: |2- + + JSON Logic filter. This filter can be used to perform complex filtering by grouping rules. + + For example, using such a filter you can get all resources created by you: + + - {"and":[{"==":[{"var":"owner"},""]}]} + + Details about the syntax used can be found at the link: https://jsonlogic.com/ + + Available filter_fields: ['id', 'job_id', 'created_date', 'target_last_updated']. + schema: + type: string + - name: job_id + in: query + description: A simple equality filter for the job_id field + schema: + type: integer + - name: org + in: query + description: Organization unique slug + schema: + type: string + - name: org_id + in: query + description: Organization identifier + schema: + type: integer + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: 'Which field to use when ordering the results. Available ordering_fields: + [''id'', ''job_id'', ''created_date'', ''target_last_updated'']' + schema: + type: string + - in: query + name: task_id + schema: + type: integer + description: A simple equality filter for task id + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/PaginatedConsensusReportList' + description: '' + post: + operationId: consensus_create_report + summary: Create a consensus report + parameters: + - in: query + name: rq_id + schema: + type: string + description: | + The report creation request id. Can be specified to check the report + creation status. + tags: + - consensus + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ConsensusReportCreateRequest' + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '201': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/ConsensusReport' + description: '' + '202': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/RqId' + description: | + A consensus report request has been enqueued, the request id is returned. + The request status can be checked at this endpoint by passing the rq_id + as the query parameter. If the request id is specified, this response + means the consensus report request is queued or is being processed. + '400': + description: Invalid or failed request, check the response data for details + /api/consensus/reports/{id}: + get: + operationId: consensus_retrieve_report + summary: Get consensus report details + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this consensus report. + required: true + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/ConsensusReport' + description: '' + /api/consensus/reports/{id}/data: + get: + operationId: consensus_retrieve_report_data + summary: Get consensus report contents + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this consensus report. + required: true + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + type: object + description: '' + /api/consensus/settings: + get: + operationId: consensus_list_settings + summary: List consensus settings instances + parameters: + - name: X-Organization + in: header + description: Organization unique slug + schema: + type: string + - name: filter + required: false + in: query + description: |2- + + JSON Logic filter. This filter can be used to perform complex filtering by grouping rules. + + For example, using such a filter you can get all resources created by you: + + - {"and":[{"==":[{"var":"owner"},""]}]} + + Details about the syntax used can be found at the link: https://jsonlogic.com/ + + Available filter_fields: ['id', 'task_id']. + schema: + type: string + - name: org + in: query + description: Organization unique slug + schema: + type: string + - name: org_id + in: query + description: Organization identifier + schema: + type: integer + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: 'Which field to use when ordering the results. Available ordering_fields: + [''id'']' + schema: + type: string + - name: task_id + in: query + description: A simple equality filter for the task_id field + schema: + type: integer + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/PaginatedConsensusSettingsList' + description: '' + /api/consensus/settings/{id}: + get: + operationId: consensus_retrieve_settings + summary: Get consensus settings instance details + parameters: + - in: path + name: id + schema: + type: integer + description: An id of a consensus settings instance + required: true + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/ConsensusSettings' + description: '' + patch: + operationId: consensus_partial_update_settings + summary: Update a consensus settings instance + parameters: + - in: path + name: id + schema: + type: integer + description: An id of a consensus settings instance + required: true + tags: + - consensus + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedConsensusSettingsRequest' + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/ConsensusSettings' + description: '' /api/events: get: operationId: events_list @@ -1804,7 +2212,7 @@ paths: Details about the syntax used can be found at the link: https://jsonlogic.com/ - Available filter_fields: ['task_name', 'project_name', 'assignee', 'state', 'stage', 'id', 'task_id', 'project_id', 'updated_date', 'dimension', 'type']. + Available filter_fields: ['task_name', 'project_name', 'assignee', 'state', 'stage', 'id', 'task_id', 'project_id', 'updated_date', 'dimension', 'type', 'parent_job_id']. schema: type: string - name: org @@ -1829,6 +2237,11 @@ paths: description: Number of results to return per page. schema: type: integer + - name: parent_job_id + in: query + description: A simple equality filter for the parent_job_id field + schema: + type: integer - name: project_id in: query description: A simple equality filter for the project_id field @@ -1851,7 +2264,8 @@ paths: in: query description: 'Which field to use when ordering the results. Available ordering_fields: [''task_name'', ''project_name'', ''assignee'', ''state'', ''stage'', ''id'', - ''task_id'', ''project_id'', ''updated_date'', ''dimension'', ''type'']' + ''task_id'', ''project_id'', ''updated_date'', ''dimension'', ''type'', + ''parent_job_id'']' schema: type: string - name: stage @@ -1891,11 +2305,7 @@ paths: enum: - annotation - ground_truth - - name: parent_job_id - in: query - description: A simple equality filter for the parent_job_id field - schema: - type: integer + - consensus tags: - jobs security: @@ -4782,6 +5192,38 @@ paths: responses: '204': description: The task has been deleted + /api/tasks/{id}/aggregate/: + put: + operationId: tasks_update_aggregate + summary: Aggregate data of a task + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this task. + required: true + tags: + - tasks + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TaskWriteRequest' + required: true + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '201': + description: Consensus Jobs Aggregated + '202': + description: Agreegation of Consensus Jobs started + '400': + description: Agreegating a task without data is not allowed /api/tasks/{id}/annotations/: get: operationId: tasks_retrieve_annotations @@ -6271,7 +6713,7 @@ components: readOnly: true type: allOf: - - $ref: '#/components/schemas/AnnotationIdTypeEnum' + - $ref: '#/components/schemas/Type457Enum' readOnly: true shape_type: readOnly: true @@ -6279,16 +6721,6 @@ components: oneOf: - $ref: '#/components/schemas/ShapeType' - $ref: '#/components/schemas/NullEnum' - AnnotationIdTypeEnum: - enum: - - tag - - shape - - track - type: string - description: |- - * `tag` - TAG - * `shape` - SHAPE - * `track` - TRACK AnnotationsRead: oneOf: - $ref: '#/components/schemas/LabeledData' @@ -6659,6 +7091,137 @@ components: type: string format: uri readOnly: true + ConsensusAnnotationId: + type: object + properties: + obj_id: + type: integer + readOnly: true + job_id: + type: integer + readOnly: true + type: + allOf: + - $ref: '#/components/schemas/Type457Enum' + readOnly: true + shape_type: + readOnly: true + nullable: true + oneOf: + - $ref: '#/components/schemas/ShapeType' + - $ref: '#/components/schemas/NullEnum' + ConsensusConflict: + type: object + properties: + id: + type: integer + readOnly: true + frame: + type: integer + readOnly: true + type: + allOf: + - $ref: '#/components/schemas/ConsensusConflictTypeEnum' + readOnly: true + annotation_ids: + type: array + items: + $ref: '#/components/schemas/ConsensusAnnotationId' + report_id: + type: integer + readOnly: true + required: + - annotation_ids + ConsensusConflictTypeEnum: + enum: + - NO_MATCHING_ITEM + - FAILED_ATTRIBUTE_VOTING + - NO_MATCHING_ANNOTATION + - ANNOTATION_TOO_CLOSE + - WRONG_GROUP + - FAILED_LABEL_VOTING + type: string + description: |- + * `NO_MATCHING_ITEM` - NoMatchingItemError + * `FAILED_ATTRIBUTE_VOTING` - FailedAttrVotingError + * `NO_MATCHING_ANNOTATION` - NoMatchingAnnError + * `ANNOTATION_TOO_CLOSE` - AnnotationsTooCloseError + * `WRONG_GROUP` - WrongGroupError + * `FAILED_LABEL_VOTING` - FailedLabelVotingError + ConsensusReport: + type: object + properties: + id: + type: integer + readOnly: true + job_id: + type: integer + nullable: true + readOnly: true + task_id: + type: integer + nullable: true + readOnly: true + summary: + $ref: '#/components/schemas/ConsensusReportSummary' + created_date: + type: string + format: date-time + readOnly: true + target_last_updated: + type: string + format: date-time + readOnly: true + required: + - summary + ConsensusReportCreateRequest: + type: object + properties: + task_id: + type: integer + writeOnly: true + required: + - task_id + ConsensusReportSummary: + type: object + properties: + frame_count: + type: integer + conflict_count: + type: integer + conflicts_by_type: + type: object + additionalProperties: + type: integer + required: + - conflict_count + - conflicts_by_type + - frame_count + ConsensusSettings: + type: object + properties: + id: + type: integer + readOnly: true + task_id: + type: integer + nullable: true + readOnly: true + iou_threshold: + type: number + format: double + description: Used for distinction between matched / unmatched shapes + agreement_score_threshold: + type: number + format: double + description: | + Confidence threshold for output annotations + quorum: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: | + Minimum count for a label and attribute voting results to be counted CredentialsTypeEnum: enum: - KEY_SECRET_KEY_PAIR @@ -7546,6 +8109,12 @@ components: allOf: - $ref: '#/components/schemas/Storage' nullable: true + parent_job_id: + type: integer + maximum: 2147483647 + minimum: 0 + nullable: true + readOnly: true required: - issues - labels @@ -7573,10 +8142,12 @@ components: enum: - annotation - ground_truth + - consensus type: string description: |- * `annotation` - ANNOTATION * `ground_truth` - GROUND_TRUTH + * `consensus` - CONSENSUS JobWriteRequest: type: object properties: @@ -8217,6 +8788,66 @@ components: type: array items: $ref: '#/components/schemas/CommentRead' + PaginatedConsensusConflictList: + type: object + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ConsensusConflict' + PaginatedConsensusReportList: + type: object + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ConsensusReport' + PaginatedConsensusSettingsList: + type: object + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ConsensusSettings' PaginatedInvitationReadList: type: object properties: @@ -8593,6 +9224,24 @@ components: message: type: string minLength: 1 + PatchedConsensusSettingsRequest: + type: object + properties: + iou_threshold: + type: number + format: double + description: Used for distinction between matched / unmatched shapes + agreement_score_threshold: + type: number + format: double + description: | + Confidence threshold for output annotations + quorum: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: | + Minimum count for a label and attribute voting results to be counted PatchedDataMetaWriteRequest: type: object properties: @@ -8846,6 +9495,9 @@ components: allOf: - $ref: '#/components/schemas/StorageRequest' nullable: true + consensus_jobs_per_segment: + type: integer + nullable: true PatchedUserRequest: type: object properties: @@ -9766,6 +10418,12 @@ components: $ref: '#/components/schemas/JobsSummary' labels: $ref: '#/components/schemas/LabelsSummary' + consensus_jobs_per_segment: + type: integer + maximum: 2147483647 + minimum: -2147483648 + readOnly: true + nullable: true required: - jobs - labels @@ -9814,6 +10472,9 @@ components: allOf: - $ref: '#/components/schemas/StorageRequest' nullable: true + consensus_jobs_per_segment: + type: integer + nullable: true required: - name TasksSummary: @@ -9924,6 +10585,16 @@ components: nullable: true required: - name + Type457Enum: + enum: + - tag + - shape + - track + type: string + description: |- + * `tag` - TAG + * `shape` - SHAPE + * `track` - TRACK User: type: object properties: From 2f7438a91be8b0fb51f8134927f8f0784ba06f4f Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 7 Jul 2024 13:28:54 +0530 Subject: [PATCH 072/301] merged new multiple django app migrations into single migration --- .../apps/consensus/migrations/0001_initial.py | 32 ++++++++++++++----- ...sk_consensus_jobs_per_segment_and_more.py} | 16 +++++++++- 2 files changed, 39 insertions(+), 9 deletions(-) rename cvat/apps/engine/migrations/{0079_job_parent_job_id_task_consensus_jobs_per_segment.py => 0079_job_parent_job_id_task_consensus_jobs_per_segment_and_more.py} (51%) diff --git a/cvat/apps/consensus/migrations/0001_initial.py b/cvat/apps/consensus/migrations/0001_initial.py index 1cbbeec3baa..d239d4916b8 100644 --- a/cvat/apps/consensus/migrations/0001_initial.py +++ b/cvat/apps/consensus/migrations/0001_initial.py @@ -1,7 +1,7 @@ -# Generated by Django 4.2.11 on 2024-07-05 18:04 +# Generated by Django 4.2.11 on 2024-07-07 07:43 -import django.db.models.deletion from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -9,7 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("engine", "0080_alter_job_type"), + ("engine", "0079_job_parent_job_id_task_consensus_jobs_per_segment_and_more"), ] operations = [ @@ -19,7 +19,10 @@ class Migration(migrations.Migration): ( "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", ), ), ("agreement_score_threshold", models.FloatField(default=0)), @@ -43,7 +46,10 @@ class Migration(migrations.Migration): ( "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", ), ), ("created_date", models.DateTimeField(auto_now_add=True)), @@ -77,7 +83,10 @@ class Migration(migrations.Migration): ( "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", ), ), ("frame", models.PositiveIntegerField()), @@ -111,7 +120,10 @@ class Migration(migrations.Migration): ( "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", ), ), ("obj_id", models.PositiveIntegerField()), @@ -119,7 +131,11 @@ class Migration(migrations.Migration): ( "type", models.CharField( - choices=[("tag", "TAG"), ("shape", "SHAPE"), ("track", "TRACK")], + choices=[ + ("tag", "TAG"), + ("shape", "SHAPE"), + ("track", "TRACK"), + ], max_length=32, ), ), diff --git a/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment.py b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment_and_more.py similarity index 51% rename from cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment.py rename to cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment_and_more.py index c7230f6161e..bc7b8703355 100644 --- a/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment.py +++ b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment_and_more.py @@ -1,5 +1,6 @@ -# Generated by Django 4.2.11 on 2024-06-22 02:00 +# Generated by Django 4.2.11 on 2024-07-07 07:43 +import cvat.apps.engine.models from django.db import migrations, models @@ -20,4 +21,17 @@ class Migration(migrations.Migration): name="consensus_jobs_per_segment", field=models.IntegerField(blank=True, default=0), ), + migrations.AlterField( + model_name="job", + name="type", + field=models.CharField( + choices=[ + ("annotation", "ANNOTATION"), + ("ground_truth", "GROUND_TRUTH"), + ("consensus", "CONSENSUS"), + ], + default=cvat.apps.engine.models.JobType["ANNOTATION"], + max_length=32, + ), + ), ] From 2b43a12f9dd6d8c04f1d2273d408dddbc553734b Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 7 Jul 2024 14:45:13 +0530 Subject: [PATCH 073/301] updatede schema.yml --- cvat/schema.yml | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/cvat/schema.yml b/cvat/schema.yml index 76c300d5434..2d6d9691f1e 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1804,7 +1804,7 @@ paths: Details about the syntax used can be found at the link: https://jsonlogic.com/ - Available filter_fields: ['task_name', 'project_name', 'assignee', 'state', 'stage', 'id', 'task_id', 'project_id', 'updated_date', 'dimension', 'type']. + Available filter_fields: ['task_name', 'project_name', 'assignee', 'state', 'stage', 'id', 'task_id', 'project_id', 'updated_date', 'dimension', 'type', 'parent_job_id']. schema: type: string - name: org @@ -1829,6 +1829,11 @@ paths: description: Number of results to return per page. schema: type: integer + - name: parent_job_id + in: query + description: A simple equality filter for the parent_job_id field + schema: + type: integer - name: project_id in: query description: A simple equality filter for the project_id field @@ -1851,7 +1856,8 @@ paths: in: query description: 'Which field to use when ordering the results. Available ordering_fields: [''task_name'', ''project_name'', ''assignee'', ''state'', ''stage'', ''id'', - ''task_id'', ''project_id'', ''updated_date'', ''dimension'', ''type'']' + ''task_id'', ''project_id'', ''updated_date'', ''dimension'', ''type'', + ''parent_job_id'']' schema: type: string - name: stage @@ -1891,11 +1897,7 @@ paths: enum: - annotation - ground_truth - - name: parent_job_id - in: query - description: A simple equality filter for the parent_job_id field - schema: - type: integer + - consensus tags: - jobs security: @@ -8097,6 +8099,12 @@ components: allOf: - $ref: '#/components/schemas/Storage' nullable: true + parent_job_id: + type: integer + maximum: 2147483647 + minimum: 0 + nullable: true + readOnly: true required: - issues - labels @@ -8124,10 +8132,12 @@ components: enum: - annotation - ground_truth + - consensus type: string description: |- * `annotation` - ANNOTATION * `ground_truth` - GROUND_TRUTH + * `consensus` - CONSENSUS JobWriteRequest: type: object properties: @@ -9417,6 +9427,9 @@ components: allOf: - $ref: '#/components/schemas/StorageRequest' nullable: true + consensus_jobs_per_segment: + type: integer + nullable: true PatchedUserRequest: type: object properties: @@ -10432,6 +10445,12 @@ components: $ref: '#/components/schemas/JobsSummary' labels: $ref: '#/components/schemas/LabelsSummary' + consensus_jobs_per_segment: + type: integer + maximum: 2147483647 + minimum: -2147483648 + readOnly: true + nullable: true required: - jobs - labels @@ -10480,6 +10499,9 @@ components: allOf: - $ref: '#/components/schemas/StorageRequest' nullable: true + consensus_jobs_per_segment: + type: integer + nullable: true required: - name TasksSummary: From 803143022863ba6b8fc17a9827b3483e335dba7a Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 7 Jul 2024 15:20:27 +0530 Subject: [PATCH 074/301] updated schema.yml --- cvat/schema.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cvat/schema.yml b/cvat/schema.yml index 2d6d9691f1e..dd3b11b57e9 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -9427,7 +9427,7 @@ components: allOf: - $ref: '#/components/schemas/StorageRequest' nullable: true - consensus_jobs_per_segment: + consensus_jobs_per_normal_job: type: integer nullable: true PatchedUserRequest: @@ -10445,7 +10445,7 @@ components: $ref: '#/components/schemas/JobsSummary' labels: $ref: '#/components/schemas/LabelsSummary' - consensus_jobs_per_segment: + consensus_jobs_per_normal_job: type: integer maximum: 2147483647 minimum: -2147483648 @@ -10499,7 +10499,7 @@ components: allOf: - $ref: '#/components/schemas/StorageRequest' nullable: true - consensus_jobs_per_segment: + consensus_jobs_per_normal_job: type: integer nullable: true required: From 2305e176604f4890c70115d31f25e3058dd92f06 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 7 Jul 2024 15:21:15 +0530 Subject: [PATCH 075/301] merged newly added multiple migration files into one --- ..._consensus_jobs_per_normal_job_and_more.py | 37 +++++++++++++++++++ ..._job_id_task_consensus_jobs_per_segment.py | 23 ------------ 2 files changed, 37 insertions(+), 23 deletions(-) create mode 100644 cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_normal_job_and_more.py delete mode 100644 cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment.py diff --git a/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_normal_job_and_more.py b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_normal_job_and_more.py new file mode 100644 index 00000000000..5931af8cba6 --- /dev/null +++ b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_normal_job_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.13 on 2024-07-07 09:18 + +import cvat.apps.engine.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("engine", "0078_alter_cloudstorage_credentials"), + ] + + operations = [ + migrations.AddField( + model_name="job", + name="parent_job_id", + field=models.PositiveIntegerField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name="task", + name="consensus_jobs_per_normal_job", + field=models.IntegerField(blank=True, default=0), + ), + migrations.AlterField( + model_name="job", + name="type", + field=models.CharField( + choices=[ + ("annotation", "ANNOTATION"), + ("ground_truth", "GROUND_TRUTH"), + ("consensus", "CONSENSUS"), + ], + default=cvat.apps.engine.models.JobType["ANNOTATION"], + max_length=32, + ), + ), + ] diff --git a/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment.py b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment.py deleted file mode 100644 index c7230f6161e..00000000000 --- a/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.11 on 2024-06-22 02:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0078_alter_cloudstorage_credentials"), - ] - - operations = [ - migrations.AddField( - model_name="job", - name="parent_job_id", - field=models.PositiveIntegerField(blank=True, default=None, null=True), - ), - migrations.AddField( - model_name="task", - name="consensus_jobs_per_segment", - field=models.IntegerField(blank=True, default=0), - ), - ] From a836a837c00a73a184589cd8dd2a7cb4fea6f540 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 7 Jul 2024 15:25:58 +0530 Subject: [PATCH 076/301] changed `consensus_jobs_per_segment` to `consensus_jobs_per_normal_job` --- cvat-core/src/server-response-types.ts | 2 +- cvat-core/src/session-implementation.ts | 4 ++-- cvat-core/src/session.ts | 8 ++++---- cvat-ui/src/actions/tasks-actions.ts | 6 +++--- .../consensus-configuration-form.tsx | 12 ++++++------ .../create-task-page/create-task-content.tsx | 2 +- cvat-ui/src/components/job-item/job-item.tsx | 18 +++++++++--------- cvat-ui/src/components/task-page/details.tsx | 8 ++++---- cvat-ui/src/components/task-page/job-list.tsx | 4 ++-- cvat/apps/engine/backup.py | 2 +- cvat/apps/engine/models.py | 2 +- cvat/apps/engine/serializers.py | 14 +++++++------- cvat/apps/engine/task.py | 2 +- 13 files changed, 42 insertions(+), 42 deletions(-) diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index 11e8d84ac1b..7cda80b6485 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -120,7 +120,7 @@ export interface SerializedTask { subset: string; updated_date: string; url: string; - consensus_jobs_per_segment: number; + consensus_jobs_per_normal_job: number; } export interface SerializedJob { diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 88ec26222af..17da511cb75 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -695,8 +695,8 @@ export function implementTask(Task: typeof TaskClass): typeof TaskClass { taskSpec.source_storage = this.sourceStorage.toJSON(); } - if (this.consensusJobsPerSegment) { - taskSpec.consensus_jobs_per_segment = this.consensusJobsPerSegment; + if (this.consensusJobsPerNormalJob) { + taskSpec.consensus_jobs_per_normal_job = this.consensusJobsPerNormalJob; } const taskDataSpec = { diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 4bdd14a7fef..bdfa0de9b8e 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -725,7 +725,7 @@ export class Task extends Session { public readonly organization: number | null; public readonly progress: { count: number; completed: number }; public readonly jobs: Job[]; - public readonly consensusJobsPerSegment: number; + public readonly consensusJobsPerNormalJob: number; public readonly startFrame: number; public readonly stopFrame: number; @@ -780,7 +780,7 @@ export class Task extends Session { cloud_storage_id: undefined, sorting_method: undefined, files: undefined, - consensus_jobs_per_segment: undefined, + consensus_jobs_per_normal_job: undefined, quality_settings: undefined, }; @@ -965,8 +965,8 @@ export class Task extends Session { copyData: { get: () => data.copy_data, }, - consensusJobsPerSegment: { - get: () => data.consensus_jobs_per_segment, + consensusJobsPerNormalJob: { + get: () => data.consensus_jobs_per_normal_job, }, labels: { get: () => [...data.labels], diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index 836f3fabd1a..b05c55c65d1 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -215,7 +215,7 @@ ThunkAction { sorting_method: data.advanced.sortingMethod, source_storage: new Storage(data.advanced.sourceStorage || { location: StorageLocation.LOCAL }).toJSON(), target_storage: new Storage(data.advanced.targetStorage || { location: StorageLocation.LOCAL }).toJSON(), - consensus_jobs_per_segment: 0, + consensus_jobs_per_normal_job: data.consensus.consensusJobsPerNormalJob, }; if (data.projectId) { @@ -254,8 +254,8 @@ ThunkAction { if (data.cloudStorageId) { description.cloud_storage_id = data.cloudStorageId; } - if (data.consensus.consensusJobsPerSegment) { - description.consensus_jobs_per_segment = +data.consensus.consensusJobsPerSegment; + if (data.consensus.consensusJobsPerNormalJob) { + description.consensus_jobs_per_normal_job = +data.consensus.consensusJobsPerNormalJob; } const taskInstance = new cvat.classes.Task(description); diff --git a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx index 6b21ddcc556..9bf3884bf02 100644 --- a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx @@ -10,11 +10,11 @@ import Form, { FormInstance, RuleObject } from 'antd/lib/form'; import { Store } from 'antd/lib/form/interface'; export interface ConsensusConfiguration { - consensusJobsPerSegment: number; + consensusJobsPerNormalJob: number; } const initialValues: ConsensusConfiguration = { - consensusJobsPerSegment: 0, + consensusJobsPerNormalJob: 0, }; interface Props { @@ -88,11 +88,11 @@ class ConsensusConfigurationForm extends React.PureComponent { } /* eslint-disable class-methods-use-this */ - private renderConsensusJobsPerSegment(): JSX.Element { + private renderconsensusJobsPerNormalJob(): JSX.Element { return ( {
- {this.renderConsensusJobsPerSegment()} + {this.renderconsensusJobsPerNormalJob()}
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 e209270c9e6..5e9b42a0f8c 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 @@ -86,7 +86,7 @@ const defaultState: State = { useProjectTargetStorage: true, }, consensus: { - consensusJobsPerSegment: 0, + consensusJobsPerNormalJob: 0, }, labels: [], files: { diff --git a/cvat-ui/src/components/job-item/job-item.tsx b/cvat-ui/src/components/job-item/job-item.tsx index 05905b8b2ff..5b831633b53 100644 --- a/cvat-ui/src/components/job-item/job-item.tsx +++ b/cvat-ui/src/components/job-item/job-item.tsx @@ -27,7 +27,7 @@ import { import { useIsMounted } from 'utils/hooks'; import UserSelector from 'components/task-page/user-selector'; import CVATTooltip from 'components/common/cvat-tooltip'; -import { Collapse } from 'antd'; +import Collapse from 'antd/lib/collapse'; import JobActionsMenu from './job-actions-menu'; interface Props { @@ -115,15 +115,15 @@ function JobItem(props: Props): JSX.Element { const frameCountPercent = ((job.frameCount / (task.size || 1)) * 100).toFixed(0); const frameCountPercentRepresentation = frameCountPercent === '0' ? '<1' : frameCountPercent; let jobName = `Job #${job.id}`; - if (task.consensusJobsPerSegment && job.type !== JobType.GROUND_TRUTH) { + if (task.consensusJobsPerNormalJob && job.type !== JobType.GROUND_TRUTH) { jobName = job.type === JobType.CONSENSUS ? `Consensus Job #${job.id}` : `Normal Job #${job.id}`; } - let consensusJob: Job[] = []; - if (task.consensusJobsPerSegment) { - consensusJob = task.jobs.filter((eachJob: Job) => eachJob.parentJobId === id).reverse(); + let consensusJobs: Job[] = []; + if (task.consensusJobsPerNormalJob) { + consensusJobs = task.jobs.filter((eachJob: Job) => eachJob.parent_job_id === id).reverse(); } - const consensusJobView: React.JSX.Element[] = consensusJob.map((eachJob: Job) => ( + const consensusJobViews: React.JSX.Element[] = consensusJobs.map((eachJob: Job) => ( )); @@ -263,7 +263,7 @@ function JobItem(props: Props): JSX.Element { > - {consensusJob.length > 0 && + {consensusJobs.length > 0 && ( - {`${consensusJob.length} Consensus Jobs`} + {`${consensusJobs.length} Consensus Jobs`} , children: ( - consensusJobView + consensusJobViews ), }]} /> diff --git a/cvat-ui/src/components/task-page/details.tsx b/cvat-ui/src/components/task-page/details.tsx index 2f3f52e7808..614710e295f 100644 --- a/cvat-ui/src/components/task-page/details.tsx +++ b/cvat-ui/src/components/task-page/details.tsx @@ -60,7 +60,7 @@ const core = getCore(); interface State { name: string; subset: string; - consensusJobsPerSegment: number; + consensusJobsPerNormalJob: number; } type Props = DispatchToProps & StateToProps & OwnProps; @@ -72,7 +72,7 @@ class DetailsComponent extends React.PureComponent { this.state = { name: taskInstance.name, subset: taskInstance.subset, - consensusJobsPerSegment: taskInstance.consensusJobsPerSegment, + consensusJobsPerNormalJob: taskInstance.consensusJobsPerNormalJob, }; } @@ -87,7 +87,7 @@ class DetailsComponent extends React.PureComponent { } private renderTaskName(): JSX.Element { - const { name, consensusJobsPerSegment } = this.state; + const { name, consensusJobsPerNormalJob } = this.state; const { task: taskInstance, onUpdateTask } = this.props; const taskName = name; @@ -112,7 +112,7 @@ class DetailsComponent extends React.PureComponent { { - consensusJobsPerSegment > 0 && ( + consensusJobsPerNormalJob > 0 && ( Consensus Based Annotation diff --git a/cvat-ui/src/components/task-page/job-list.tsx b/cvat-ui/src/components/task-page/job-list.tsx index 40467fdef03..8e07810f07a 100644 --- a/cvat-ui/src/components/task-page/job-list.tsx +++ b/cvat-ui/src/components/task-page/job-list.tsx @@ -61,8 +61,8 @@ function setUpJobsList(jobs: Job[], query: JobsQuery): Job[] { result = result.filter((job, index) => jsonLogic.apply(filter, converted[index])); } - // primarily only normal jobs should be shown - result = result.filter((job) => job.parentJobId === null); + // consensus jobs will be under the collapse view + result = result.filter((job) => job.parent_job_id === null); return result; } diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index b29536b422c..80b2fc40f2c 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -183,7 +183,7 @@ def _prepare_task_meta(self, task): 'status', 'subset', 'labels', - 'consensus_jobs_per_segment', + 'consensus_jobs_per_normal_job', } return self._prepare_meta(allowed_fields, task) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 322e3106a03..12e49da9c73 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -419,7 +419,7 @@ class Task(TimestampedModel): blank=True, on_delete=models.SET_NULL, related_name='+') target_storage = models.ForeignKey('Storage', null=True, default=None, blank=True, on_delete=models.SET_NULL, related_name='+') - consensus_jobs_per_segment = models.IntegerField(default=0, blank=True) + consensus_jobs_per_normal_job = models.IntegerField(default=0, blank=True) # Extend default permission model class Meta: diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index bcaf9243716..513b83227e3 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1111,7 +1111,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='*') - consensus_jobs_per_segment = serializers.ReadOnlyField(required=False, allow_null=True) + consensus_jobs_per_normal_job = serializers.ReadOnlyField(required=False, allow_null=True) class Meta: model = models.Task @@ -1120,7 +1120,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', - 'consensus_jobs_per_segment', + 'consensus_jobs_per_normal_job', ) read_only_fields = fields extra_kwargs = { @@ -1136,13 +1136,13 @@ class TaskWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): project_id = serializers.IntegerField(required=False, allow_null=True) target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) - consensus_jobs_per_segment = serializers.IntegerField(required=False, allow_null=True) + consensus_jobs_per_normal_job = serializers.IntegerField(required=False, allow_null=True) 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', 'consensus_jobs_per_segment', + 'target_storage', 'source_storage', 'consensus_jobs_per_normal_job', ) write_once_fields = ('overlap', 'segment_size') @@ -1323,10 +1323,10 @@ def validate(self, attrs): if sublabels != target_project_sublabel_names.get(label): raise serializers.ValidationError('All task or project label names must be mapped to the target project') - consensus_jobs_per_segment = attrs.get('consensus_jobs_per_segment', self.instance.consensus_jobs_per_segment if self.instance else None) + consensus_jobs_per_normal_job = attrs.get('consensus_jobs_per_normal_job', self.instance.consensus_jobs_per_normal_job if self.instance else None) - if consensus_jobs_per_segment and (consensus_jobs_per_segment == 1 or consensus_jobs_per_segment < 0): - raise serializers.ValidationError("Consensus job per segment should be greater than or equal to 0 and not 1") + if consensus_jobs_per_normal_job and (consensus_jobs_per_normal_job == 1 or consensus_jobs_per_normal_job < 0): + raise serializers.ValidationError("Consensus jobs per normal job should be greater than or equal to 0 and not 1") return attrs diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index d2e097bac74..9edc73dc601 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -184,7 +184,7 @@ def _save_task_to_db(db_task: models.Task, *, job_file_mapping: Optional[JobFile db_job.make_dirs() # consensus jobs use the same `db_segment` as the normal job, thus data not duplicated in backups, exports - for _ in range(db_task.consensus_jobs_per_segment): + for _ in range(db_task.consensus_jobs_per_normal_job): consensus_db_job = models.Job(segment=db_segment, parent_job_id=db_job.id, type=models.JobType.CONSENSUS) consensus_db_job.save() consensus_db_job.make_dirs() From c964e5f23187a38442275303821127f92d02b709 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 7 Jul 2024 15:30:27 +0530 Subject: [PATCH 077/301] changed `onSubmit` to `onChange` for `consensusBlock` in task creation --- cvat-core/src/session.ts | 1 + .../consensus-configuration-form.tsx | 35 +++++++++---------- .../create-task-page/create-task-content.tsx | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index bdfa0de9b8e..9d22a810046 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -521,6 +521,7 @@ export class Job extends Session { this.#data.data_chunk_size = initialData.data_chunk_size ?? this.#data.data_chunk_size; this.#data.mode = initialData.mode ?? this.#data.mode; this.#data.created_date = initialData.created_date ?? this.#data.created_date; + this.#data.parent_job_id = initialData.parent_job_id ?? this.#data.parent_job_id; if (Array.isArray(initialData.labels)) { this.#data.labels = initialData.labels.map((labelData) => { diff --git a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx index 9bf3884bf02..21368d6cc5e 100644 --- a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx @@ -1,5 +1,4 @@ -// Copyright (C) 2020-2022 Intel Corporation -// Copyright (C) 2022-2024 CVAT.ai Corporation +// Copyright (C) 2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -7,7 +6,6 @@ import React, { RefObject } from 'react'; import { Row, Col } from 'antd/lib/grid'; import Input from 'antd/lib/input'; import Form, { FormInstance, RuleObject } from 'antd/lib/form'; -import { Store } from 'antd/lib/form/interface'; export interface ConsensusConfiguration { consensusJobsPerNormalJob: number; @@ -18,7 +16,7 @@ const initialValues: ConsensusConfiguration = { }; interface Props { - onSubmit(values: ConsensusConfiguration): Promise; + onChange(values: ConsensusConfiguration): void; } const isNumber = ({ @@ -61,21 +59,16 @@ class ConsensusConfigurationForm extends React.PureComponent { this.formRef = React.createRef(); } - public submit(): Promise { - const { - onSubmit, - } = this.props; + private handleChangeName(e: React.ChangeEvent): void { + const { onChange } = this.props; + onChange({ + consensusJobsPerNormalJob: parseInt(e.target.value, 10), + }); + } + public submit(): Promise { if (this.formRef.current) { - return this.formRef.current.validateFields() - .then( - (values: Store): Promise => { - const entries = Object.entries(values); - return onSubmit({ - ...((Object.fromEntries(entries) as any) as ConsensusConfiguration), - }); - }, - ); + return this.formRef.current.validateFields(); } return Promise.reject(new Error('Form ref is empty')); @@ -103,7 +96,13 @@ class ConsensusConfigurationForm extends React.PureComponent { }, ]} > - + this.handleChangeName(e)} + />
); } 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 5e9b42a0f8c..8b4150c465a 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 @@ -764,7 +764,7 @@ class CreateTaskContent extends React.PureComponent ); From 53cc5a07afb8928379e30486439b207afd07b592 Mon Sep 17 00:00:00 2001 From: vidit Date: Mon, 8 Jul 2024 01:30:15 +0530 Subject: [PATCH 078/301] created a common validator to check for interger with the value filter --- .../advanced-configuration-form.tsx | 25 +----------- .../consensus-configuration-form.tsx | 39 ++----------------- cvat-ui/src/utils/validate-integer.ts | 37 ++++++++++++++++++ 3 files changed, 42 insertions(+), 59 deletions(-) create mode 100644 cvat-ui/src/utils/validate-integer.ts 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 2749147b3cf..fe7b3f715ae 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 @@ -17,6 +17,7 @@ import Text from 'antd/lib/typography/Text'; import { Store } from 'antd/lib/form/interface'; import CVATTooltip from 'components/common/cvat-tooltip'; import patterns from 'utils/validation-patterns'; +import { isInteger } from 'utils/validate-integer'; import { StorageLocation } from 'reducers'; import SourceStorageField from 'components/storage/source-storage-field'; import TargetStorageField from 'components/storage/target-storage-field'; @@ -92,30 +93,6 @@ function validateURL(_: RuleObject, value: string): Promise { return Promise.resolve(); } -export const isInteger = ({ min, max }: { min?: number; max?: number; }) => ( - _: RuleObject, - value?: number | string, -): Promise => { - if (typeof value === 'undefined' || value === '') { - return Promise.resolve(); - } - - const intValue = +value; - if (Number.isNaN(intValue) || !Number.isInteger(intValue)) { - return Promise.reject(new Error('Value must be a positive integer')); - } - - if (typeof min !== 'undefined' && intValue < min) { - return Promise.reject(new Error(`Value must be more than ${min}`)); - } - - if (typeof max !== 'undefined' && intValue > max) { - return Promise.reject(new Error(`Value must be less than ${max}`)); - } - - return Promise.resolve(); -}; - const validateOverlapSize: RuleRender = ({ getFieldValue }): RuleObject => ({ validator(_: RuleObject, value?: string | number): Promise { if (typeof value !== 'undefined' && value !== '') { diff --git a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx index 21368d6cc5e..c38a23313f9 100644 --- a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx @@ -5,7 +5,8 @@ import React, { RefObject } from 'react'; import { Row, Col } from 'antd/lib/grid'; import Input from 'antd/lib/input'; -import Form, { FormInstance, RuleObject } from 'antd/lib/form'; +import Form, { FormInstance } from 'antd/lib/form'; +import { isInteger } from 'utils/validate-integer'; export interface ConsensusConfiguration { consensusJobsPerNormalJob: number; @@ -19,38 +20,6 @@ interface Props { onChange(values: ConsensusConfiguration): void; } -const isNumber = ({ - min, - max, - toBeSkipped, -}: { min?: number; max?: number; toBeSkipped?: number }) => ( - _: RuleObject, - value?: number | string, -): Promise => { - if (typeof value === 'undefined' || value === '') { - return Promise.resolve(); - } - - const intValue = +value; - if (!Number.isFinite(intValue) && !Number.isInteger(intValue)) { - return Promise.reject(new Error('Value must be a positive integer')); - } - - if (typeof min !== 'undefined' && intValue < min) { - return Promise.reject(new Error(`Value must be more than ${min}`)); - } - - if (typeof max !== 'undefined' && intValue > max) { - return Promise.reject(new Error(`Value must be less than ${max}`)); - } - - if (typeof toBeSkipped !== 'undefined' && intValue === toBeSkipped) { - return Promise.reject(new Error(`Value shouldn't be equal to ${toBeSkipped}`)); - } - - return Promise.resolve(); -}; - class ConsensusConfigurationForm extends React.PureComponent { private formRef: RefObject; @@ -88,10 +57,10 @@ class ConsensusConfigurationForm extends React.PureComponent { name='consensusJobsPerNormalJob' rules={[ { - validator: isNumber({ + validator: isInteger({ min: 0, max: 10, - toBeSkipped: 1, + filter: (intValue: number): boolean => intValue !== 1, }), }, ]} diff --git a/cvat-ui/src/utils/validate-integer.ts b/cvat-ui/src/utils/validate-integer.ts new file mode 100644 index 00000000000..9c6178757c0 --- /dev/null +++ b/cvat-ui/src/utils/validate-integer.ts @@ -0,0 +1,37 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { RuleObject } from 'antd/lib/form'; + +export const isInteger = ({ min, max, filter }: { + min?: number; + max?: number; + filter?: (intValue: number) => boolean; +}) => ( + _: RuleObject, + value?: number | string, +): Promise => { + if (typeof value === 'undefined' || value === '') { + return Promise.resolve(); + } + + const intValue = +value; + if (Number.isNaN(intValue) || !Number.isInteger(intValue)) { + return Promise.reject(new Error('Value must be a positive integer')); + } + + if (typeof min !== 'undefined' && intValue < min) { + return Promise.reject(new Error(`Value must be more than ${min}`)); + } + + if (typeof max !== 'undefined' && intValue > max) { + return Promise.reject(new Error(`Value must be less than ${max}`)); + } + + if (filter && !filter(intValue)) { + return Promise.reject(new Error(`Value can not be equal to ${intValue}`)); + } + + return Promise.resolve(); +}; From 05b77ad414aa9fc0288d5df341933f1495a2bc4c Mon Sep 17 00:00:00 2001 From: vidit Date: Mon, 8 Jul 2024 02:28:59 +0530 Subject: [PATCH 079/301] added margin at top of consensus job view collapse block --- cvat-ui/src/components/job-item/job-item.tsx | 2 +- cvat-ui/src/components/job-item/styles.scss | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cvat-ui/src/components/job-item/job-item.tsx b/cvat-ui/src/components/job-item/job-item.tsx index 5b831633b53..1b8f7a2a761 100644 --- a/cvat-ui/src/components/job-item/job-item.tsx +++ b/cvat-ui/src/components/job-item/job-item.tsx @@ -266,7 +266,7 @@ function JobItem(props: Props): JSX.Element { {consensusJobs.length > 0 && ( .ant-collapse-header { + align-items: center; + } + } + + .job-actions-menu { + position: absolute; + top: $grid-unit-size * 6.5; + } + } .ant-menu.cvat-job-item-menu { From 8502b0a55bfb19f21e03a4d94a57378ab08570d8 Mon Sep 17 00:00:00 2001 From: vidit Date: Mon, 8 Jul 2024 14:26:03 +0530 Subject: [PATCH 080/301] fixed `schema.yml` conflicts after merging `consensus-job` branch --- cvat/schema.yml | 671 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 660 insertions(+), 11 deletions(-) diff --git a/cvat/schema.yml b/cvat/schema.yml index dd3b11b57e9..9df70d4769f 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1049,6 +1049,414 @@ paths: responses: '204': description: The comment has been deleted + /api/consensus/conflicts: + get: + operationId: consensus_list_conflicts + summary: List annotation conflicts in a consensus report + parameters: + - name: X-Organization + in: header + description: Organization unique slug + schema: + type: string + - name: filter + required: false + in: query + description: |2- + + JSON Logic filter. This filter can be used to perform complex filtering by grouping rules. + + For example, using such a filter you can get all resources created by you: + + - {"and":[{"==":[{"var":"owner"},""]}]} + + Details about the syntax used can be found at the link: https://jsonlogic.com/ + + Available filter_fields: ['id', 'frame', 'type', 'job_id', 'task_id']. + schema: + type: string + - name: frame + in: query + description: A simple equality filter for the frame field + schema: + type: integer + - name: job_id + in: query + description: A simple equality filter for the job_id field + schema: + type: integer + - name: org + in: query + description: Organization unique slug + schema: + type: string + - name: org_id + in: query + description: Organization identifier + schema: + type: integer + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: report_id + schema: + type: integer + description: A simple equality filter for report id + - name: sort + required: false + in: query + description: 'Which field to use when ordering the results. Available ordering_fields: + [''id'', ''frame'', ''type'', ''job_id'', ''task_id'']' + schema: + type: string + - name: task_id + in: query + description: A simple equality filter for the task_id field + schema: + type: integer + - name: type + in: query + description: A simple equality filter for the type field + schema: + type: string + enum: + - NO_MATCHING_ITEM + - FAILED_ATTRIBUTE_VOTING + - NO_MATCHING_ANNOTATION + - ANNOTATION_TOO_CLOSE + - WRONG_GROUP + - FAILED_LABEL_VOTING + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/PaginatedConsensusConflictList' + description: '' + /api/consensus/reports: + get: + operationId: consensus_list_reports + summary: List consensus reports + parameters: + - name: X-Organization + in: header + description: Organization unique slug + schema: + type: string + - name: filter + required: false + in: query + description: |2- + + JSON Logic filter. This filter can be used to perform complex filtering by grouping rules. + + For example, using such a filter you can get all resources created by you: + + - {"and":[{"==":[{"var":"owner"},""]}]} + + Details about the syntax used can be found at the link: https://jsonlogic.com/ + + Available filter_fields: ['id', 'job_id', 'created_date', 'target_last_updated']. + schema: + type: string + - name: job_id + in: query + description: A simple equality filter for the job_id field + schema: + type: integer + - name: org + in: query + description: Organization unique slug + schema: + type: string + - name: org_id + in: query + description: Organization identifier + schema: + type: integer + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: 'Which field to use when ordering the results. Available ordering_fields: + [''id'', ''job_id'', ''created_date'', ''target_last_updated'']' + schema: + type: string + - in: query + name: task_id + schema: + type: integer + description: A simple equality filter for task id + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/PaginatedConsensusReportList' + description: '' + post: + operationId: consensus_create_report + summary: Create a consensus report + parameters: + - in: query + name: rq_id + schema: + type: string + description: | + The report creation request id. Can be specified to check the report + creation status. + tags: + - consensus + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ConsensusReportCreateRequest' + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '201': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/ConsensusReport' + description: '' + '202': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/RqId' + description: | + A consensus report request has been enqueued, the request id is returned. + The request status can be checked at this endpoint by passing the rq_id + as the query parameter. If the request id is specified, this response + means the consensus report request is queued or is being processed. + '400': + description: Invalid or failed request, check the response data for details + /api/consensus/reports/{id}: + get: + operationId: consensus_retrieve_report + summary: Get consensus report details + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this consensus report. + required: true + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/ConsensusReport' + description: '' + /api/consensus/reports/{id}/data: + get: + operationId: consensus_retrieve_report_data + summary: Get consensus report contents + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this consensus report. + required: true + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + type: object + description: '' + /api/consensus/settings: + get: + operationId: consensus_list_settings + summary: List consensus settings instances + parameters: + - name: X-Organization + in: header + description: Organization unique slug + schema: + type: string + - name: filter + required: false + in: query + description: |2- + + JSON Logic filter. This filter can be used to perform complex filtering by grouping rules. + + For example, using such a filter you can get all resources created by you: + + - {"and":[{"==":[{"var":"owner"},""]}]} + + Details about the syntax used can be found at the link: https://jsonlogic.com/ + + Available filter_fields: ['id', 'task_id']. + schema: + type: string + - name: org + in: query + description: Organization unique slug + schema: + type: string + - name: org_id + in: query + description: Organization identifier + schema: + type: integer + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: 'Which field to use when ordering the results. Available ordering_fields: + [''id'']' + schema: + type: string + - name: task_id + in: query + description: A simple equality filter for the task_id field + schema: + type: integer + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/PaginatedConsensusSettingsList' + description: '' + /api/consensus/settings/{id}: + get: + operationId: consensus_retrieve_settings + summary: Get consensus settings instance details + parameters: + - in: path + name: id + schema: + type: integer + description: An id of a consensus settings instance + required: true + tags: + - consensus + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/ConsensusSettings' + description: '' + patch: + operationId: consensus_partial_update_settings + summary: Update a consensus settings instance + parameters: + - in: path + name: id + schema: + type: integer + description: An id of a consensus settings instance + required: true + tags: + - consensus + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedConsensusSettingsRequest' + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '200': + content: + application/vnd.cvat+json: + schema: + $ref: '#/components/schemas/ConsensusSettings' + description: '' /api/events: get: operationId: events_list @@ -5171,6 +5579,38 @@ paths: responses: '204': description: The task has been deleted + /api/tasks/{id}/aggregate/: + put: + operationId: tasks_update_aggregate + summary: Aggregate data of a task + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this task. + required: true + tags: + - tasks + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TaskWriteRequest' + required: true + security: + - sessionAuth: [] + csrfAuth: [] + tokenAuth: [] + - signatureAuth: [] + - basicAuth: [] + responses: + '201': + description: Consensus Jobs Aggregated + '202': + description: Agreegation of Consensus Jobs started + '400': + description: Agreegating a task without data is not allowed /api/tasks/{id}/annotations/: get: operationId: tasks_retrieve_annotations @@ -6819,7 +7259,7 @@ components: readOnly: true type: allOf: - - $ref: '#/components/schemas/AnnotationIdTypeEnum' + - $ref: '#/components/schemas/Type457Enum' readOnly: true shape_type: readOnly: true @@ -6827,16 +7267,6 @@ components: oneOf: - $ref: '#/components/schemas/ShapeType' - $ref: '#/components/schemas/NullEnum' - AnnotationIdTypeEnum: - enum: - - tag - - shape - - track - type: string - description: |- - * `tag` - TAG - * `shape` - SHAPE - * `track` - TRACK AnnotationsRead: oneOf: - $ref: '#/components/schemas/LabeledData' @@ -7207,6 +7637,137 @@ components: type: string format: uri readOnly: true + ConsensusAnnotationId: + type: object + properties: + obj_id: + type: integer + readOnly: true + job_id: + type: integer + readOnly: true + type: + allOf: + - $ref: '#/components/schemas/Type457Enum' + readOnly: true + shape_type: + readOnly: true + nullable: true + oneOf: + - $ref: '#/components/schemas/ShapeType' + - $ref: '#/components/schemas/NullEnum' + ConsensusConflict: + type: object + properties: + id: + type: integer + readOnly: true + frame: + type: integer + readOnly: true + type: + allOf: + - $ref: '#/components/schemas/ConsensusConflictTypeEnum' + readOnly: true + annotation_ids: + type: array + items: + $ref: '#/components/schemas/ConsensusAnnotationId' + report_id: + type: integer + readOnly: true + required: + - annotation_ids + ConsensusConflictTypeEnum: + enum: + - NO_MATCHING_ITEM + - FAILED_ATTRIBUTE_VOTING + - NO_MATCHING_ANNOTATION + - ANNOTATION_TOO_CLOSE + - WRONG_GROUP + - FAILED_LABEL_VOTING + type: string + description: |- + * `NO_MATCHING_ITEM` - NoMatchingItemError + * `FAILED_ATTRIBUTE_VOTING` - FailedAttrVotingError + * `NO_MATCHING_ANNOTATION` - NoMatchingAnnError + * `ANNOTATION_TOO_CLOSE` - AnnotationsTooCloseError + * `WRONG_GROUP` - WrongGroupError + * `FAILED_LABEL_VOTING` - FailedLabelVotingError + ConsensusReport: + type: object + properties: + id: + type: integer + readOnly: true + job_id: + type: integer + nullable: true + readOnly: true + task_id: + type: integer + nullable: true + readOnly: true + summary: + $ref: '#/components/schemas/ConsensusReportSummary' + created_date: + type: string + format: date-time + readOnly: true + target_last_updated: + type: string + format: date-time + readOnly: true + required: + - summary + ConsensusReportCreateRequest: + type: object + properties: + task_id: + type: integer + writeOnly: true + required: + - task_id + ConsensusReportSummary: + type: object + properties: + frame_count: + type: integer + conflict_count: + type: integer + conflicts_by_type: + type: object + additionalProperties: + type: integer + required: + - conflict_count + - conflicts_by_type + - frame_count + ConsensusSettings: + type: object + properties: + id: + type: integer + readOnly: true + task_id: + type: integer + nullable: true + readOnly: true + iou_threshold: + type: number + format: double + description: Used for distinction between matched / unmatched shapes + agreement_score_threshold: + type: number + format: double + description: | + Confidence threshold for output annotations + quorum: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: | + Minimum count for a label and attribute voting results to be counted CredentialsTypeEnum: enum: - KEY_SECRET_KEY_PAIR @@ -8778,6 +9339,66 @@ components: type: array items: $ref: '#/components/schemas/CommentRead' + PaginatedConsensusConflictList: + type: object + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ConsensusConflict' + PaginatedConsensusReportList: + type: object + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ConsensusReport' + PaginatedConsensusSettingsList: + type: object + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ConsensusSettings' PaginatedInvitationReadList: type: object properties: @@ -9174,6 +9795,24 @@ components: message: type: string minLength: 1 + PatchedConsensusSettingsRequest: + type: object + properties: + iou_threshold: + type: number + format: double + description: Used for distinction between matched / unmatched shapes + agreement_score_threshold: + type: number + format: double + description: | + Confidence threshold for output annotations + quorum: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: | + Minimum count for a label and attribute voting results to be counted PatchedDataMetaWriteRequest: type: object properties: @@ -10612,6 +11251,16 @@ components: nullable: true required: - name + Type457Enum: + enum: + - tag + - shape + - track + type: string + description: |- + * `tag` - TAG + * `shape` - SHAPE + * `track` - TRACK User: type: object properties: From 1bf9420569ef0403ead28a2f28ecc219d99288e9 Mon Sep 17 00:00:00 2001 From: vidit Date: Mon, 8 Jul 2024 14:28:23 +0530 Subject: [PATCH 081/301] fixed migration files after merging `consensus-job` branch --- .../apps/consensus/migrations/0001_initial.py | 7 +++- ..._consensus_jobs_per_normal_job_and_more.py | 2 +- ...ask_consensus_jobs_per_segment_and_more.py | 37 ------------------- 3 files changed, 6 insertions(+), 40 deletions(-) delete mode 100644 cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment_and_more.py diff --git a/cvat/apps/consensus/migrations/0001_initial.py b/cvat/apps/consensus/migrations/0001_initial.py index d239d4916b8..e58ba913678 100644 --- a/cvat/apps/consensus/migrations/0001_initial.py +++ b/cvat/apps/consensus/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-07-07 07:43 +# Generated by Django 4.2.13 on 2024-07-08 08:57 from django.db import migrations, models import django.db.models.deletion @@ -9,7 +9,10 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("engine", "0079_job_parent_job_id_task_consensus_jobs_per_segment_and_more"), + ( + "engine", + "0079_job_parent_job_id_task_consensus_jobs_per_normal_job_and_more", + ), ] operations = [ diff --git a/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_normal_job_and_more.py b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_normal_job_and_more.py index 5931af8cba6..1c2cb9a3789 100644 --- a/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_normal_job_and_more.py +++ b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_normal_job_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.13 on 2024-07-07 09:18 +# Generated by Django 4.2.13 on 2024-07-08 08:57 import cvat.apps.engine.models from django.db import migrations, models diff --git a/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment_and_more.py b/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment_and_more.py deleted file mode 100644 index bc7b8703355..00000000000 --- a/cvat/apps/engine/migrations/0079_job_parent_job_id_task_consensus_jobs_per_segment_and_more.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 4.2.11 on 2024-07-07 07:43 - -import cvat.apps.engine.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("engine", "0078_alter_cloudstorage_credentials"), - ] - - operations = [ - migrations.AddField( - model_name="job", - name="parent_job_id", - field=models.PositiveIntegerField(blank=True, default=None, null=True), - ), - migrations.AddField( - model_name="task", - name="consensus_jobs_per_segment", - field=models.IntegerField(blank=True, default=0), - ), - migrations.AlterField( - model_name="job", - name="type", - field=models.CharField( - choices=[ - ("annotation", "ANNOTATION"), - ("ground_truth", "GROUND_TRUTH"), - ("consensus", "CONSENSUS"), - ], - default=cvat.apps.engine.models.JobType["ANNOTATION"], - max_length=32, - ), - ), - ] From c986c808f75387eea931cad03bb2cc28e4fea770 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 9 Jul 2024 01:09:19 +0530 Subject: [PATCH 082/301] made endpoint `task/{id}/aggregate` functional even after adding new endpoints to `consensus` --- cvat/apps/engine/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index d69b56f6ee2..5947bd36933 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -965,10 +965,14 @@ def export_backup(self, request, pk=None): def aggregate(self, request, pk=None): task = self.get_object() - return merge_task( + merged_response = merge_task( task, request ) + if isinstance(merged_response, str): + return Response(status=status.HTTP_202_ACCEPTED) + else: + return merged_response @transaction.atomic def perform_update(self, serializer): From d7aed9dfee312c9dad72c7a906fee4c18a006c57 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 9 Jul 2024 01:10:03 +0530 Subject: [PATCH 083/301] Added the case of an annotated job might have no annotations --- cvat/apps/consensus/merge_consensus_jobs.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index 43032479655..03fc55d0779 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -6,6 +6,7 @@ from uuid import uuid4 import datumaro as dm +from rest_framework.exceptions import ValidationError import django_rq from datumaro.components.operations import IntersectMerge from django.conf import settings @@ -23,7 +24,7 @@ from cvat.apps.consensus.models import ConsensusSettings from cvat.apps.dataset_manager.bindings import import_dm_annotations from cvat.apps.dataset_manager.task import PatchAction, patch_job_data -from cvat.apps.engine.models import Job, JobType, Task +from cvat.apps.engine.models import Job, JobType, Task, StateChoice from cvat.apps.engine.serializers import RqIdSerializer from cvat.apps.engine.utils import ( define_dependent_job, @@ -41,6 +42,10 @@ def get_consensus_jobs(task_id: int) -> Dict[int, List[int]]: segment__task_id=task_id, type=JobType.CONSENSUS.value ): assert job.parent_job_id + + # if the job is in NEW state, it means that the job isn't annotated + if job.state == StateChoice.NEW.value: + continue jobs.setdefault(job.parent_job_id, []).append(job.id) return jobs @@ -53,6 +58,9 @@ def get_annotations(job_id: int) -> dm.Dataset: @transaction.atomic def _merge_consensus_jobs(task_id: int) -> None: jobs = get_consensus_jobs(task_id) + if not jobs: + raise ValidationError("No annotated consensus jobs found") + consensus_settings = ConsensusSettings.objects.filter(task=task_id).first() merger = IntersectMerge( conf=IntersectMerge.Conf( From 4b550355f5c50c9fba642d6ebd947c0e7b63b95e Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 9 Jul 2024 01:11:19 +0530 Subject: [PATCH 084/301] changed `consensusJobPerSegment` to `consensusJobPerNormalJob` --- cvat-ui/src/components/actions-menu/actions-menu.tsx | 6 +++--- cvat-ui/src/components/analytics-page/analytics-page.tsx | 2 +- cvat-ui/src/containers/actions-menu/actions-menu.tsx | 2 +- cvat-ui/src/reducers/notifications-reducer.ts | 6 ++++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/cvat-ui/src/components/actions-menu/actions-menu.tsx b/cvat-ui/src/components/actions-menu/actions-menu.tsx index 60590e625b3..d47d9a5c918 100644 --- a/cvat-ui/src/components/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/components/actions-menu/actions-menu.tsx @@ -22,7 +22,7 @@ interface Props { dumpers: AnnotationFormats['dumpers']; inferenceIsActive: boolean; taskDimension: DimensionType; - consensusJobsPerSegment: number; + consensusJobsPerNormalJob: number; onClickMenu: (params: MenuInfo) => void; } @@ -44,7 +44,7 @@ function ActionsMenuComponent(props: Props): JSX.Element { projectID, bugTracker, inferenceIsActive, - consensusJobsPerSegment, + consensusJobsPerNormalJob, onClickMenu, } = props; @@ -114,7 +114,7 @@ function ActionsMenuComponent(props: Props): JSX.Element { ), 50]); - if (consensusJobsPerSegment) { + if (consensusJobsPerNormalJob) { menuItems.push([( , }] : []), - ...((instanceType === 'task' && instance.consensusJobsPerSegment) ? [{ + ...((instanceType === 'task' && instance.consensusJobsPerNormalJob) ? [{ key: AnalyticsTabs.CONSENSUS, label: 'Consensus', children: , diff --git a/cvat-ui/src/containers/actions-menu/actions-menu.tsx b/cvat-ui/src/containers/actions-menu/actions-menu.tsx index b2ae0b2964d..8ed14d4f7f5 100644 --- a/cvat-ui/src/containers/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/containers/actions-menu/actions-menu.tsx @@ -127,7 +127,7 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): inferenceIsActive={inferenceIsActive} onClickMenu={onClickMenu} taskDimension={taskInstance.dimension} - consensusJobsPerSegment={taskInstance.consensusJobsPerSegment} + consensusJobsPerNormalJob={taskInstance.consensusJobsPerNormalJob} /> ); } diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index 466190eba38..37a9b9cceb3 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -761,8 +761,10 @@ export default function (state = defaultState, action: AnyAction): Notifications ...state.messages, tasks: { ...state.messages.tasks, - mergingConsensusDone: `Consensus Jobs in the [task ${taskID}](/tasks/${taskID})\ - have been merged`, + mergingConsensusDone: { + message: `Consensus Jobs in the [task ${taskID}](/tasks/${taskID}) \ + have been merged`, + }, }, }, }; From e988defa80153059f63d3e0587ab8b637e1f36bc Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 9 Jul 2024 01:20:41 +0530 Subject: [PATCH 085/301] improved the colour of buttons under consensus config action --- .../consensus/consensus-settings-form.tsx | 1 + cvat-ui/src/components/consensus/styles.scss | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cvat-ui/src/components/consensus/consensus-settings-form.tsx b/cvat-ui/src/components/consensus/consensus-settings-form.tsx index 52b2626b39b..78b5b9d1fc3 100644 --- a/cvat-ui/src/components/consensus/consensus-settings-form.tsx +++ b/cvat-ui/src/components/consensus/consensus-settings-form.tsx @@ -138,6 +138,7 @@ export default function ConsensusSettingsForm(props: Props): JSX.Element | null onClick={() => { form.resetFields(); }} + className='cvat-button-reset-settings' > Reset Settings diff --git a/cvat-ui/src/components/consensus/styles.scss b/cvat-ui/src/components/consensus/styles.scss index e6360614504..a4018b6d224 100644 --- a/cvat-ui/src/components/consensus/styles.scss +++ b/cvat-ui/src/components/consensus/styles.scss @@ -1,4 +1,5 @@ @import '../export-backup/styles'; +@use 'sass:color'; .consensus-modal-form { display: flex; @@ -7,7 +8,12 @@ .ant-btn-default { background-color: #1890ff; - border: none; + color: white !important; + + &:hover { + background-color: color.adjust(#1890ff, $lightness: 7%) !important; + color: white !important; + } } } @@ -22,3 +28,12 @@ } } +.cvat-button-reset-settings { + background-color: red; + color: white; + + &:hover { + background-color: color.adjust(red, $lightness: 7%) !important; + color: white !important; + } +} \ No newline at end of file From d57c45148fcf2b25a74a278d5a646513a76dd3e5 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 9 Jul 2024 13:40:07 +0530 Subject: [PATCH 086/301] fixed the position of job action menu dropdown --- cvat-ui/src/components/job-item/job-item.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/cvat-ui/src/components/job-item/job-item.tsx b/cvat-ui/src/components/job-item/job-item.tsx index 1b8f7a2a761..6a4f24306dd 100644 --- a/cvat-ui/src/components/job-item/job-item.tsx +++ b/cvat-ui/src/components/job-item/job-item.tsx @@ -259,6 +259,7 @@ function JobItem(props: Props): JSX.Element { } > From f5cf75a751e941d7947c49bf7a4d892b7e1bd3f7 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 9 Jul 2024 14:11:44 +0530 Subject: [PATCH 087/301] set the default of `quorum` to floor half of `consensus jobs` --- cvat/apps/consensus/models.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cvat/apps/consensus/models.py b/cvat/apps/consensus/models.py index c1b2e7d84b2..461aeb0acd3 100644 --- a/cvat/apps/consensus/models.py +++ b/cvat/apps/consensus/models.py @@ -61,9 +61,15 @@ class ConsensusSettings(models.Model): blank=True, ) agreement_score_threshold = models.FloatField(default=0) - quorum = models.IntegerField(default=0) + quorum = models.IntegerField(default=-1) iou_threshold = models.FloatField(default=0.5) + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + if self.quorum == -1: + self.quorum = self.task.consensus_jobs_per_normal_job // 2 + def to_dict(self): return model_to_dict(self) From ea1f3cd8671f3fb14c0e4ed1f1eba7ca21791073 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 9 Jul 2024 14:12:37 +0530 Subject: [PATCH 088/301] mentioned the type of task object in a function --- cvat-ui/src/actions/consensus-actions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat-ui/src/actions/consensus-actions.ts b/cvat-ui/src/actions/consensus-actions.ts index b6028c315b2..b89f99a2ed7 100644 --- a/cvat-ui/src/actions/consensus-actions.ts +++ b/cvat-ui/src/actions/consensus-actions.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import { ConsensusSettings } from 'cvat-core-wrapper'; +import { ConsensusSettings, Task } from 'cvat-core-wrapper'; export enum ConsensusActionTypes { OPEN_CONSENSUS_MODAL = 'OPEN_CONSENSUS_MODAL', @@ -40,7 +40,7 @@ export const consensusActions = { }; export const mergeTaskConsensusJobsAsync = ( - taskInstance: any, + taskInstance: Task, ): ThunkAction => async (dispatch) => { try { dispatch(consensusActions.mergeTaskConsensusJobs(taskInstance.id)); From 283578f32fb0dea23cc296efbbc8932991913903 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 9 Jul 2024 14:13:37 +0530 Subject: [PATCH 089/301] fixed SaasError of rule order `@use` and `@import` --- cvat-ui/src/components/consensus/styles.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat-ui/src/components/consensus/styles.scss b/cvat-ui/src/components/consensus/styles.scss index a4018b6d224..29f2190d8ac 100644 --- a/cvat-ui/src/components/consensus/styles.scss +++ b/cvat-ui/src/components/consensus/styles.scss @@ -1,5 +1,5 @@ -@import '../export-backup/styles'; @use 'sass:color'; +@import '../export-backup/styles'; .consensus-modal-form { display: flex; From bf8fa7ddb2e0608c43727105baa7a61acff0b958 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 9 Jul 2024 14:16:16 +0530 Subject: [PATCH 090/301] improved english on UI --- cvat-ui/src/components/actions-menu/actions-menu.tsx | 2 +- cvat-ui/src/components/consensus/consensus-modal.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat-ui/src/components/actions-menu/actions-menu.tsx b/cvat-ui/src/components/actions-menu/actions-menu.tsx index d47d9a5c918..993b86d2152 100644 --- a/cvat-ui/src/components/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/components/actions-menu/actions-menu.tsx @@ -119,7 +119,7 @@ function ActionsMenuComponent(props: Props): JSX.Element { - Consensus Configuration + Consensus configuration ), 55]); } diff --git a/cvat-ui/src/components/consensus/consensus-modal.tsx b/cvat-ui/src/components/consensus/consensus-modal.tsx index dafc802b7e7..1435e2f8001 100644 --- a/cvat-ui/src/components/consensus/consensus-modal.tsx +++ b/cvat-ui/src/components/consensus/consensus-modal.tsx @@ -74,7 +74,7 @@ function ConsensusModal(): JSX.Element { return ( Consensus Configurations } + title={ Consensus Configuration } open={!!instance} className='cvat-modal-export-task custom-modal-center-title' destroyOnClose From 2da336f61399fe5302e8565920c083c64e512f67 Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 13 Jul 2024 03:58:06 +0530 Subject: [PATCH 091/301] removed `parent` param from conflict which isn't presnt in `ConsensusConflict` model --- cvat/apps/consensus/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cvat/apps/consensus/views.py b/cvat/apps/consensus/views.py index e37909d3637..c3bd73b963e 100644 --- a/cvat/apps/consensus/views.py +++ b/cvat/apps/consensus/views.py @@ -78,7 +78,6 @@ class ConsensusConflictsViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): queryset = ( ConsensusConflict.objects.select_related( "report", - "report__parent", "report__job", "report__job__segment", "report__job__segment__task", From df460ce44e4039f631953e9ce85ed2531ac760c8 Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 13 Jul 2024 04:01:43 +0530 Subject: [PATCH 092/301] removed consensus config block and moved `consensus_job_per_normal_job` to advanced block in task creation --- cvat-ui/src/actions/tasks-actions.ts | 4 +- .../advanced-configuration-form.tsx | 33 +++++++ .../consensus-configuration-form.tsx | 92 ------------------- .../create-task-page/create-task-content.tsx | 35 +------ 4 files changed, 37 insertions(+), 127 deletions(-) delete mode 100644 cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index b05c55c65d1..56c13d48aeb 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -215,7 +215,7 @@ ThunkAction { sorting_method: data.advanced.sortingMethod, source_storage: new Storage(data.advanced.sourceStorage || { location: StorageLocation.LOCAL }).toJSON(), target_storage: new Storage(data.advanced.targetStorage || { location: StorageLocation.LOCAL }).toJSON(), - consensus_jobs_per_normal_job: data.consensus.consensusJobsPerNormalJob, + consensus_jobs_per_normal_job: data.advanced.consensusJobsPerNormalJob, }; if (data.projectId) { @@ -255,7 +255,7 @@ ThunkAction { description.cloud_storage_id = data.cloudStorageId; } if (data.consensus.consensusJobsPerNormalJob) { - description.consensus_jobs_per_normal_job = +data.consensus.consensusJobsPerNormalJob; + description.consensus_jobs_per_normal_job = +data.advanced.consensusJobsPerNormalJob; } const taskInstance = new cvat.classes.Task(description); 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 fe7b3f715ae..058086a4668 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 @@ -48,6 +48,7 @@ export interface AdvancedConfiguration { sortingMethod: SortingMethod; useProjectSourceStorage: boolean; useProjectTargetStorage: boolean; + consensusJobsPerNormalJob: number; sourceStorage: StorageData; targetStorage: StorageData; } @@ -60,6 +61,7 @@ const initialValues: AdvancedConfiguration = { sortingMethod: SortingMethod.LEXICOGRAPHICAL, useProjectSourceStorage: true, useProjectTargetStorage: true, + consensusJobsPerNormalJob: 0, sourceStorage: { location: StorageLocation.LOCAL, @@ -379,6 +381,32 @@ class AdvancedConfigurationForm extends React.PureComponent { ); } + private renderconsensusJobsPerNormalJob(): JSX.Element { + return ( + intValue !== 1, + }), + }, + ]} + > + this.handleChangeName(e)} + /> + + ); + } + private renderSourceStorage(): JSX.Element { const { projectId, @@ -460,6 +488,11 @@ class AdvancedConfigurationForm extends React.PureComponent { {this.renderChunkSize()} + + + {this.renderconsensusJobsPerNormalJob()} + + {this.renderBugTracker()} diff --git a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx deleted file mode 100644 index c38a23313f9..00000000000 --- a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (C) 2024 CVAT.ai Corporation -// -// SPDX-License-Identifier: MIT - -import React, { RefObject } from 'react'; -import { Row, Col } from 'antd/lib/grid'; -import Input from 'antd/lib/input'; -import Form, { FormInstance } from 'antd/lib/form'; -import { isInteger } from 'utils/validate-integer'; - -export interface ConsensusConfiguration { - consensusJobsPerNormalJob: number; -} - -const initialValues: ConsensusConfiguration = { - consensusJobsPerNormalJob: 0, -}; - -interface Props { - onChange(values: ConsensusConfiguration): void; -} - -class ConsensusConfigurationForm extends React.PureComponent { - private formRef: RefObject; - - public constructor(props: Props) { - super(props); - this.formRef = React.createRef(); - } - - private handleChangeName(e: React.ChangeEvent): void { - const { onChange } = this.props; - onChange({ - consensusJobsPerNormalJob: parseInt(e.target.value, 10), - }); - } - - public submit(): Promise { - if (this.formRef.current) { - return this.formRef.current.validateFields(); - } - - return Promise.reject(new Error('Form ref is empty')); - } - - public resetFields(): void { - if (this.formRef.current) { - this.formRef.current.resetFields(); - } - } - - /* eslint-disable class-methods-use-this */ - private renderconsensusJobsPerNormalJob(): JSX.Element { - return ( - intValue !== 1, - }), - }, - ]} - > - this.handleChangeName(e)} - /> - - ); - } - - public render(): JSX.Element { - return ( -
- - - {this.renderconsensusJobsPerNormalJob()} - - -
- ); - } -} - -export default ConsensusConfigurationForm; 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 8b4150c465a..9fdd09ab414 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 @@ -26,7 +26,6 @@ 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 ConsensusConfigurationForm, { ConsensusConfiguration } from './consensus-configuration-form'; type TabName = 'local' | 'share' | 'remote' | 'cloudStorage'; const core = getCore(); @@ -36,7 +35,6 @@ export interface CreateTaskData { basic: BaseConfiguration; subset: string; advanced: AdvancedConfiguration; - consensus: ConsensusConfiguration; labels: any[]; files: Files; activeFileManagerTab: TabName; @@ -85,9 +83,6 @@ const defaultState: State = { useProjectSourceStorage: true, useProjectTargetStorage: true, }, - consensus: { - consensusJobsPerNormalJob: 0, - }, labels: [], files: { local: [], @@ -157,7 +152,7 @@ function filterFiles(remoteFiles: RemoteFile[], many: boolean): RemoteFile[] { class CreateTaskContent extends React.PureComponent { private basicConfigurationComponent: RefObject; private advancedConfigurationComponent: RefObject; - private consensusConfigurationComponent: RefObject; + // private consensusConfigurationComponent: RefObject; private fileManagerComponent: any; public constructor(props: Props & RouteComponentProps) { @@ -165,7 +160,7 @@ class CreateTaskContent extends React.PureComponent(); this.advancedConfigurationComponent = React.createRef(); - this.consensusConfigurationComponent = React.createRef(); + // this.consensusConfigurationComponent = React.createRef(); } public componentDidMount(): void { @@ -192,7 +187,6 @@ class CreateTaskContent extends React.PureComponent { this.basicConfigurationComponent.current?.resetFields(); this.advancedConfigurationComponent.current?.resetFields(); - this.consensusConfigurationComponent.current?.resetFields(); this.fileManagerComponent.reset(); @@ -262,14 +256,6 @@ class CreateTaskContent extends React.PureComponent => ( - new Promise((resolve) => { - this.setState({ - consensus: { ...values }, - }, resolve); - }) - ); - private handleTaskSubsetChange = (value: string): void => { this.setState({ subset: value, @@ -450,9 +436,6 @@ class CreateTaskContent extends React.PureComponent { @@ -583,7 +566,6 @@ class CreateTaskContent extends React.PureComponent - - - ); - } - private renderSubsetBlock(): JSX.Element | null { const { projectId, subset } = this.state; @@ -998,7 +968,6 @@ class CreateTaskContent extends React.PureComponent {many ? this.renderFooterMultiTasks() : this.renderFooterSingleTask() } From b5b6e7fcf01336d360aa8ce7b7d52621f998d4a6 Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 13 Jul 2024 04:28:03 +0530 Subject: [PATCH 093/301] moved `consensus based annotation` tag into task description --- cvat-ui/src/components/task-page/details.tsx | 27 +++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/cvat-ui/src/components/task-page/details.tsx b/cvat-ui/src/components/task-page/details.tsx index 614710e295f..93d542524df 100644 --- a/cvat-ui/src/components/task-page/details.tsx +++ b/cvat-ui/src/components/task-page/details.tsx @@ -87,7 +87,7 @@ class DetailsComponent extends React.PureComponent { } private renderTaskName(): JSX.Element { - const { name, consensusJobsPerNormalJob } = this.state; + const { name } = this.state; const { task: taskInstance, onUpdateTask } = this.props; const taskName = name; @@ -111,19 +111,13 @@ class DetailsComponent extends React.PureComponent { {taskName} - { - consensusJobsPerNormalJob > 0 && ( - - Consensus Based Annotation - - ) - }
); } private renderDescription(): JSX.Element { const { task: taskInstance, onUpdateTask } = this.props; + const { consensusJobsPerNormalJob } = this.state; const owner = taskInstance.owner ? taskInstance.owner.username : null; const assignee = taskInstance.assignee ? taskInstance.assignee : null; const created = moment(taskInstance.createdDate).format('MMMM Do YYYY'); @@ -137,12 +131,27 @@ class DetailsComponent extends React.PureComponent { }} /> ); + const consensusTag = consensusJobsPerNormalJob > 0 && Consensus Based Annotation; return ( {owner && ( - {`Task #${taskInstance.id} Created by ${owner} on ${created}`} +
+ {consensusTag} + + Task # + {taskInstance.id} + {' '} +Created by + {' '} + {owner} + {' '} +on + {' '} + {created} + +
)} From 6f68b83ab417137a3a6f12700b68e2cc4eb7680e Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 13 Jul 2024 04:33:31 +0530 Subject: [PATCH 094/301] fixed the formatting of description HTML --- cvat-ui/src/components/task-page/details.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/cvat-ui/src/components/task-page/details.tsx b/cvat-ui/src/components/task-page/details.tsx index 93d542524df..8e5b34ab0f5 100644 --- a/cvat-ui/src/components/task-page/details.tsx +++ b/cvat-ui/src/components/task-page/details.tsx @@ -140,16 +140,7 @@ class DetailsComponent extends React.PureComponent {
{consensusTag} - Task # - {taskInstance.id} - {' '} -Created by - {' '} - {owner} - {' '} -on - {' '} - {created} + {`Task #${taskInstance.id} Created by ${owner} on ${created}`}
)} From fc5201b09a4fd5ebccc60a3fc562b687a142777b Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 13 Jul 2024 04:01:43 +0530 Subject: [PATCH 095/301] removed consensus config block and moved `consensus_job_per_normal_job` to advanced block in task creation (cherry picked from commit df460ce44e4039f631953e9ce85ed2531ac760c8) --- cvat-ui/src/actions/tasks-actions.ts | 4 +- .../advanced-configuration-form.tsx | 33 +++++++ .../consensus-configuration-form.tsx | 92 ------------------- .../create-task-page/create-task-content.tsx | 35 +------ 4 files changed, 37 insertions(+), 127 deletions(-) delete mode 100644 cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index b05c55c65d1..56c13d48aeb 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -215,7 +215,7 @@ ThunkAction { sorting_method: data.advanced.sortingMethod, source_storage: new Storage(data.advanced.sourceStorage || { location: StorageLocation.LOCAL }).toJSON(), target_storage: new Storage(data.advanced.targetStorage || { location: StorageLocation.LOCAL }).toJSON(), - consensus_jobs_per_normal_job: data.consensus.consensusJobsPerNormalJob, + consensus_jobs_per_normal_job: data.advanced.consensusJobsPerNormalJob, }; if (data.projectId) { @@ -255,7 +255,7 @@ ThunkAction { description.cloud_storage_id = data.cloudStorageId; } if (data.consensus.consensusJobsPerNormalJob) { - description.consensus_jobs_per_normal_job = +data.consensus.consensusJobsPerNormalJob; + description.consensus_jobs_per_normal_job = +data.advanced.consensusJobsPerNormalJob; } const taskInstance = new cvat.classes.Task(description); 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 fe7b3f715ae..058086a4668 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 @@ -48,6 +48,7 @@ export interface AdvancedConfiguration { sortingMethod: SortingMethod; useProjectSourceStorage: boolean; useProjectTargetStorage: boolean; + consensusJobsPerNormalJob: number; sourceStorage: StorageData; targetStorage: StorageData; } @@ -60,6 +61,7 @@ const initialValues: AdvancedConfiguration = { sortingMethod: SortingMethod.LEXICOGRAPHICAL, useProjectSourceStorage: true, useProjectTargetStorage: true, + consensusJobsPerNormalJob: 0, sourceStorage: { location: StorageLocation.LOCAL, @@ -379,6 +381,32 @@ class AdvancedConfigurationForm extends React.PureComponent { ); } + private renderconsensusJobsPerNormalJob(): JSX.Element { + return ( + intValue !== 1, + }), + }, + ]} + > + this.handleChangeName(e)} + /> + + ); + } + private renderSourceStorage(): JSX.Element { const { projectId, @@ -460,6 +488,11 @@ class AdvancedConfigurationForm extends React.PureComponent { {this.renderChunkSize()} + + + {this.renderconsensusJobsPerNormalJob()} + + {this.renderBugTracker()} diff --git a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx b/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx deleted file mode 100644 index c38a23313f9..00000000000 --- a/cvat-ui/src/components/create-task-page/consensus-configuration-form.tsx +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (C) 2024 CVAT.ai Corporation -// -// SPDX-License-Identifier: MIT - -import React, { RefObject } from 'react'; -import { Row, Col } from 'antd/lib/grid'; -import Input from 'antd/lib/input'; -import Form, { FormInstance } from 'antd/lib/form'; -import { isInteger } from 'utils/validate-integer'; - -export interface ConsensusConfiguration { - consensusJobsPerNormalJob: number; -} - -const initialValues: ConsensusConfiguration = { - consensusJobsPerNormalJob: 0, -}; - -interface Props { - onChange(values: ConsensusConfiguration): void; -} - -class ConsensusConfigurationForm extends React.PureComponent { - private formRef: RefObject; - - public constructor(props: Props) { - super(props); - this.formRef = React.createRef(); - } - - private handleChangeName(e: React.ChangeEvent): void { - const { onChange } = this.props; - onChange({ - consensusJobsPerNormalJob: parseInt(e.target.value, 10), - }); - } - - public submit(): Promise { - if (this.formRef.current) { - return this.formRef.current.validateFields(); - } - - return Promise.reject(new Error('Form ref is empty')); - } - - public resetFields(): void { - if (this.formRef.current) { - this.formRef.current.resetFields(); - } - } - - /* eslint-disable class-methods-use-this */ - private renderconsensusJobsPerNormalJob(): JSX.Element { - return ( - intValue !== 1, - }), - }, - ]} - > - this.handleChangeName(e)} - /> - - ); - } - - public render(): JSX.Element { - return ( -
- - - {this.renderconsensusJobsPerNormalJob()} - - -
- ); - } -} - -export default ConsensusConfigurationForm; 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 8b4150c465a..9fdd09ab414 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 @@ -26,7 +26,6 @@ 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 ConsensusConfigurationForm, { ConsensusConfiguration } from './consensus-configuration-form'; type TabName = 'local' | 'share' | 'remote' | 'cloudStorage'; const core = getCore(); @@ -36,7 +35,6 @@ export interface CreateTaskData { basic: BaseConfiguration; subset: string; advanced: AdvancedConfiguration; - consensus: ConsensusConfiguration; labels: any[]; files: Files; activeFileManagerTab: TabName; @@ -85,9 +83,6 @@ const defaultState: State = { useProjectSourceStorage: true, useProjectTargetStorage: true, }, - consensus: { - consensusJobsPerNormalJob: 0, - }, labels: [], files: { local: [], @@ -157,7 +152,7 @@ function filterFiles(remoteFiles: RemoteFile[], many: boolean): RemoteFile[] { class CreateTaskContent extends React.PureComponent { private basicConfigurationComponent: RefObject; private advancedConfigurationComponent: RefObject; - private consensusConfigurationComponent: RefObject; + // private consensusConfigurationComponent: RefObject; private fileManagerComponent: any; public constructor(props: Props & RouteComponentProps) { @@ -165,7 +160,7 @@ class CreateTaskContent extends React.PureComponent(); this.advancedConfigurationComponent = React.createRef(); - this.consensusConfigurationComponent = React.createRef(); + // this.consensusConfigurationComponent = React.createRef(); } public componentDidMount(): void { @@ -192,7 +187,6 @@ class CreateTaskContent extends React.PureComponent { this.basicConfigurationComponent.current?.resetFields(); this.advancedConfigurationComponent.current?.resetFields(); - this.consensusConfigurationComponent.current?.resetFields(); this.fileManagerComponent.reset(); @@ -262,14 +256,6 @@ class CreateTaskContent extends React.PureComponent => ( - new Promise((resolve) => { - this.setState({ - consensus: { ...values }, - }, resolve); - }) - ); - private handleTaskSubsetChange = (value: string): void => { this.setState({ subset: value, @@ -450,9 +436,6 @@ class CreateTaskContent extends React.PureComponent { @@ -583,7 +566,6 @@ class CreateTaskContent extends React.PureComponent - - - ); - } - private renderSubsetBlock(): JSX.Element | null { const { projectId, subset } = this.state; @@ -998,7 +968,6 @@ class CreateTaskContent extends React.PureComponent {many ? this.renderFooterMultiTasks() : this.renderFooterSingleTask() } From dc12a1f2848a6eea0567b5412ba2fb93e9ce8f0b Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 13 Jul 2024 05:00:18 +0530 Subject: [PATCH 096/301] changed reference to `data.consensus` instead of `data.advanced` --- cvat-ui/src/actions/tasks-actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index 56c13d48aeb..ee8723d4cd1 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -254,7 +254,7 @@ ThunkAction { if (data.cloudStorageId) { description.cloud_storage_id = data.cloudStorageId; } - if (data.consensus.consensusJobsPerNormalJob) { + if (data.advanced.consensusJobsPerNormalJob) { description.consensus_jobs_per_normal_job = +data.advanced.consensusJobsPerNormalJob; } From a5512e7741ca6332008cc480e295f38e4089577d Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 13 Jul 2024 05:01:28 +0530 Subject: [PATCH 097/301] added default value of `consensus jobs per normal job` in task creation form and set it's max value to 10 --- .../components/create-task-page/advanced-configuration-form.tsx | 2 +- cvat-ui/src/components/create-task-page/create-task-content.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 058086a4668..12bb79340c6 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 @@ -400,8 +400,8 @@ class AdvancedConfigurationForm extends React.PureComponent { size='large' type='number' min={0} + max={10} step={1} - // onChange={(e) => this.handleChangeName(e)} />
); 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 9fdd09ab414..79b77c36199 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 @@ -82,6 +82,7 @@ const defaultState: State = { }, useProjectSourceStorage: true, useProjectTargetStorage: true, + consensusJobsPerNormalJob: 0, }, labels: [], files: { From 2a07da7b3f142b392eb5ee2849a95ecb93097692 Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 13 Jul 2024 05:53:34 +0530 Subject: [PATCH 098/301] while merging only consider `normal job` if it's in `annotation` stage --- cvat/apps/consensus/merge_consensus_jobs.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index 03fc55d0779..192f626e2be 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -6,13 +6,13 @@ from uuid import uuid4 import datumaro as dm -from rest_framework.exceptions import ValidationError import django_rq from datumaro.components.operations import IntersectMerge from django.conf import settings from django.db import transaction from django.utils import timezone from rest_framework import status +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from cvat.apps.consensus.consensus_reports import ( @@ -24,7 +24,7 @@ from cvat.apps.consensus.models import ConsensusSettings from cvat.apps.dataset_manager.bindings import import_dm_annotations from cvat.apps.dataset_manager.task import PatchAction, patch_job_data -from cvat.apps.engine.models import Job, JobType, Task, StateChoice +from cvat.apps.engine.models import Job, JobType, StageChoice, StateChoice, Task from cvat.apps.engine.serializers import RqIdSerializer from cvat.apps.engine.utils import ( define_dependent_job, @@ -48,6 +48,17 @@ def get_consensus_jobs(task_id: int) -> Dict[int, List[int]]: continue jobs.setdefault(job.parent_job_id, []).append(job.id) + parent_job_ids = list(jobs.keys()) + + for parent_job_id in parent_job_ids: + if ( + Job.objects.filter(id=parent_job_id, type=JobType.ANNOTATION.value).first().stage + == StageChoice.ANNOTATION.value + ): + continue + else: + jobs.pop(parent_job_id) + return jobs @@ -59,7 +70,9 @@ def get_annotations(job_id: int) -> dm.Dataset: def _merge_consensus_jobs(task_id: int) -> None: jobs = get_consensus_jobs(task_id) if not jobs: - raise ValidationError("No annotated consensus jobs found") + raise ValidationError( + "No annotated consensus jobs found or no normal jobs in annotation stage" + ) consensus_settings = ConsensusSettings.objects.filter(task=task_id).first() merger = IntersectMerge( From d8955f23b27c1f0c3781776e348988cf2f90c700 Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 13 Jul 2024 05:54:42 +0530 Subject: [PATCH 099/301] after merging annotation change normal job `state` to `in_progress` and stage to `validation` --- cvat/apps/consensus/merge_consensus_jobs.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index 192f626e2be..c0a2349a893 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -114,6 +114,10 @@ def _merge_consensus_jobs(task_id: int) -> None: errors=merger.errors, consensus_job_data_providers=consensus_job_data_providers, ) + parent_job = Job.objects.filter(id=parent_job_id, type=JobType.ANNOTATION.value).first() + parent_job.stage = StageChoice.VALIDATION.value + parent_job.state = StateChoice.IN_PROGRESS.value + parent_job.save() task_report_data = generate_task_consensus_report(job_comparison_reports) return save_report(task_id, jobs, task_report_data, job_comparison_reports) From 32190e29ee9b8c3884654304202ba7727d5aa556 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 14 Jul 2024 02:53:51 +0530 Subject: [PATCH 100/301] removed notifcation when saving consensus settings and added a loading action on Save button while saving them --- .../consensus/consensus-settings-form.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/cvat-ui/src/components/consensus/consensus-settings-form.tsx b/cvat-ui/src/components/consensus/consensus-settings-form.tsx index 78b5b9d1fc3..2afb215bda1 100644 --- a/cvat-ui/src/components/consensus/consensus-settings-form.tsx +++ b/cvat-ui/src/components/consensus/consensus-settings-form.tsx @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT import './styles.scss'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { QuestionCircleOutlined } from '@ant-design/icons/lib/icons'; import Text from 'antd/lib/typography/Text'; import InputNumber from 'antd/lib/input-number'; @@ -13,6 +13,7 @@ import CVATTooltip from 'components/common/cvat-tooltip'; import { ConsensusSettings } from 'cvat-core-wrapper'; import { Button } from 'antd/lib'; import notification from 'antd/lib/notification'; +import { LoadingOutlined } from '@ant-design/icons'; interface Props { settings: ConsensusSettings | null; @@ -22,6 +23,8 @@ interface Props { export default function ConsensusSettingsForm(props: Props): JSX.Element | null { const { settings, setConsensusSettings } = props; + const [updatingConsensusSetting, setUpdatingConsensusSetting] = useState(false); + if (!settings) { return ( No quality settings @@ -46,10 +49,8 @@ export default function ConsensusSettingsForm(props: Props): JSX.Element | null settings.agreementScoreThreshold = values.agreementScoreThreshold / 100; try { - notification.info({ - message: 'Updating Consensus Settings', - }); const responseSettings = await settings.save(); + setUpdatingConsensusSetting(true); setConsensusSettings(responseSettings); } catch (error: unknown) { notification.error({ @@ -59,14 +60,13 @@ export default function ConsensusSettingsForm(props: Props): JSX.Element | null throw error; } await settings.save(); - notification.info({ - message: 'Consensus Settings have been updated', - }); } return settings; } catch (e) { return false; + } finally { + setUpdatingConsensusSetting(false); } }, [settings]); @@ -145,7 +145,9 @@ export default function ConsensusSettingsForm(props: Props): JSX.Element | null From f754bc66b376764bd1a55c679afd08edabf29177 Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 25 Jul 2024 21:17:24 +0530 Subject: [PATCH 113/301] fixed conflict in `engine` migrations --- ..._job_id_and_more.py => 0082_job_parent_job_id_and_more.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename cvat/apps/engine/migrations/{0079_job_parent_job_id_and_more.py => 0082_job_parent_job_id_and_more.py} (89%) diff --git a/cvat/apps/engine/migrations/0079_job_parent_job_id_and_more.py b/cvat/apps/engine/migrations/0082_job_parent_job_id_and_more.py similarity index 89% rename from cvat/apps/engine/migrations/0079_job_parent_job_id_and_more.py rename to cvat/apps/engine/migrations/0082_job_parent_job_id_and_more.py index f42f949a689..d3eb2ea020c 100644 --- a/cvat/apps/engine/migrations/0079_job_parent_job_id_and_more.py +++ b/cvat/apps/engine/migrations/0082_job_parent_job_id_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.13 on 2024-07-18 17:56 +# Generated by Django 4.2.13 on 2024-07-25 15:46 import cvat.apps.engine.models from django.db import migrations, models @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ("engine", "0078_alter_cloudstorage_credentials"), + ("engine", "0081_job_assignee_updated_date_and_more"), ] operations = [ From 9866dbde2cfde0df25c7837294e8664497884ed7 Mon Sep 17 00:00:00 2001 From: vidit Date: Thu, 25 Jul 2024 21:19:37 +0530 Subject: [PATCH 114/301] removed unwanted commented code --- cvat-ui/src/components/create-task-page/create-task-content.tsx | 2 -- 1 file changed, 2 deletions(-) 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 42c1fecfd15..2139d5d8a61 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 @@ -153,7 +153,6 @@ function filterFiles(remoteFiles: RemoteFile[], many: boolean): RemoteFile[] { class CreateTaskContent extends React.PureComponent { private basicConfigurationComponent: RefObject; private advancedConfigurationComponent: RefObject; - // private consensusConfigurationComponent: RefObject; private fileManagerComponent: any; public constructor(props: Props & RouteComponentProps) { @@ -161,7 +160,6 @@ class CreateTaskContent extends React.PureComponent(); this.advancedConfigurationComponent = React.createRef(); - // this.consensusConfigurationComponent = React.createRef(); } public componentDidMount(): void { From 033b6a8c9f1422a77444d260952f53446f02d018 Mon Sep 17 00:00:00 2001 From: vidit Date: Sat, 27 Jul 2024 16:22:15 +0530 Subject: [PATCH 115/301] added missing `,` in `TaskReadSerializer` fields --- cvat/apps/engine/serializers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 53302c8fd46..160f16378e9 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1127,8 +1127,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', - 'assignee_updated_date' - 'consensus_jobs_per_regular_job', + 'assignee_updated_date', 'consensus_jobs_per_regular_job', ) read_only_fields = fields extra_kwargs = { From 6d49bf5ea82693a383877a8a0d43f66ecd0b0170 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 28 Jul 2024 01:16:56 +0530 Subject: [PATCH 116/301] added the max. 10 consensus jobs per regular job condition --- cvat/apps/engine/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 160f16378e9..01e846e6852 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1340,8 +1340,8 @@ def validate(self, attrs): consensus_jobs_per_regular_job = attrs.get('consensus_jobs_per_regular_job', self.instance.consensus_jobs_per_regular_job if self.instance else None) - if consensus_jobs_per_regular_job and (consensus_jobs_per_regular_job == 1 or consensus_jobs_per_regular_job < 0): - raise serializers.ValidationError("Consensus jobs per regular job should be greater than or equal to 0 and not 1") + if consensus_jobs_per_regular_job and (consensus_jobs_per_regular_job == 1 or consensus_jobs_per_regular_job < 0 or consensus_jobs_per_regular_job > 10): + raise serializers.ValidationError("Consensus jobs per regular job shouldn't be negative, less than 10 except 1") return attrs From 2c906bb5b360fd27796ed7ffb4c45c2d516cac79 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 28 Jul 2024 01:17:34 +0530 Subject: [PATCH 117/301] added tests related to setting consensus jobs per regular job --- tests/python/rest_api/test_tasks.py | 25 ++ tests/python/shared/assets/annotations.json | 30 ++ .../shared/assets/cvat_db/cvat_data.tar.bz2 | Bin 169947 -> 172651 bytes tests/python/shared/assets/cvat_db/data.json | 376 +++++++++++++++--- tests/python/shared/assets/jobs.json | 197 ++++++++- tests/python/shared/assets/labels.json | 13 +- .../shared/assets/quality_settings.json | 18 +- tests/python/shared/assets/tasks.json | 74 +++- tests/python/shared/assets/users.json | 2 +- 9 files changed, 682 insertions(+), 53 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index f8f8510fdc7..ce69e8fec8e 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -433,6 +433,28 @@ def test_can_create_with_assignee(self, admin_user, users_by_name, assignee): assert task.assignee is None assert task.assignee_updated_date is None + @pytest.mark.parametrize( + "consensus_jobs_per_regular_job, success", [(0, True), (1, False), (2, True), (11, False)] + ) + def test_can_create_with_consensus_jobs_per_regular_job( + self, admin_user, consensus_jobs_per_regular_job, success + ): + task_spec = { + "name": "test task creation with assignee", + "labels": [{"name": "car"}], + "consensus_jobs_per_regular_job": consensus_jobs_per_regular_job, + } + + with make_api_client(admin_user) as api_client: + if success: + (task, response) = api_client.tasks_api.create(task_write_request=task_spec) + assert response.status == HTTPStatus.CREATED + assert task.consensus_jobs_per_regular_job == consensus_jobs_per_regular_job + else: + with pytest.raises(ApiException) as exc: + (task, response) = api_client.tasks_api.create(task_write_request=task_spec) + assert exc.status == HTTPStatus.BAD_REQUEST + @pytest.mark.usefixtures("restore_db_per_class") class TestGetData: @@ -2275,6 +2297,7 @@ def test_can_import_backup_for_task_in_nondefault_state(self, tasks, mode): task = self.client.tasks.retrieve(task_json["id"]) jobs = task.get_jobs() for j in jobs: + # print(j) j.update({"stage": "validation"}) self._test_can_restore_backup_task(task_json["id"]) @@ -2292,6 +2315,8 @@ def _test_can_restore_backup_task(self, task_id: int): old_jobs = task.get_jobs() new_jobs = restored_task.get_jobs() assert len(old_jobs) == len(new_jobs) + # print(old_jobs) + # print(new_jobs) for old_job, new_job in zip(old_jobs, new_jobs): assert old_job.status == new_job.status diff --git a/tests/python/shared/assets/annotations.json b/tests/python/shared/assets/annotations.json index 67241270fe5..2e9c752fbc2 100644 --- a/tests/python/shared/assets/annotations.json +++ b/tests/python/shared/assets/annotations.json @@ -4625,6 +4625,30 @@ "tags": [], "tracks": [], "version": 0 + }, + "35": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "36": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "37": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 + }, + "38": { + "shapes": [], + "tags": [], + "tracks": [], + "version": 0 } }, "task": { @@ -7551,6 +7575,12 @@ "tags": [], "tracks": [], "version": 0 + }, + "26": { + "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..903efe3b15c7c9ca42d55230bfb5ad66f36d8b9b 100644 GIT binary patch literal 172651 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 UYw(qZZ@;M7p}y5uL8?>t2d)luQ7C_rE-k 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 G@sn(3K_=_~ literal 169947 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 Ih!1tZ9706NU&7B?3NJk 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 Date: Sun, 28 Jul 2024 13:58:58 +0530 Subject: [PATCH 118/301] fixed OKS implementation --- cvat/apps/quality_control/quality_reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat/apps/quality_control/quality_reports.py b/cvat/apps/quality_control/quality_reports.py index adf10732b56..6214796f0ca 100644 --- a/cvat/apps/quality_control/quality_reports.py +++ b/cvat/apps/quality_control/quality_reports.py @@ -726,7 +726,7 @@ def _OKS(a, b, sigma=0.1, bbox=None, scale=None, visibility_a=None, visibility_b dists = np.linalg.norm(p1 - p2, axis=1) return np.sum( - visibility_a * visibility_b * np.exp(-(dists**2) / (2 * scale * (2 * sigma) ** 2)) + visibility_a * visibility_b * np.exp((visibility_a == visibility_b)*(-(dists**2) / (2 * scale * (2 * sigma) ** 2))) ) / np.sum(visibility_a | visibility_b, dtype=float) From 1b206a35e2b26dbb317b23c449998a320ad25265 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 28 Jul 2024 15:08:27 +0530 Subject: [PATCH 119/301] added sigma for OKS calculation in consensus settings --- cvat-core/src/consensus-settings.ts | 11 ++++++++++ cvat-core/src/server-response-types.ts | 1 + .../consensus/consensus-settings-form.tsx | 14 +++++++++++++ cvat/apps/consensus/consensus_reports.py | 1 + cvat/apps/consensus/merge_consensus_jobs.py | 4 +++- .../apps/consensus/migrations/0001_initial.py | 7 ++++--- cvat/apps/consensus/models.py | 1 + cvat/apps/consensus/serializers.py | 7 +++++++ ....py => 0082_job_parent_job_id_and_more.py} | 4 ++-- cvat/schema.yml | 20 ++++++++++++++----- 10 files changed, 59 insertions(+), 11 deletions(-) rename cvat/apps/engine/migrations/{0079_job_parent_job_id_and_more.py => 0082_job_parent_job_id_and_more.py} (89%) diff --git a/cvat-core/src/consensus-settings.ts b/cvat-core/src/consensus-settings.ts index 53ea1ad4dc7..91ea744c87a 100644 --- a/cvat-core/src/consensus-settings.ts +++ b/cvat-core/src/consensus-settings.ts @@ -12,6 +12,7 @@ export default class ConsensusSettings { #iouThreshold: number; #quorum: number; #agreementScoreThreshold: number; + #sigma: number; constructor(initialData: SerializedConsensusSettingsData) { this.#id = initialData.id; @@ -19,6 +20,7 @@ export default class ConsensusSettings { this.#iouThreshold = initialData.iou_threshold; this.#agreementScoreThreshold = initialData.agreement_score_threshold; this.#quorum = initialData.quorum; + this.#sigma = initialData.sigma; } get id(): number { @@ -45,6 +47,14 @@ export default class ConsensusSettings { this.#quorum = newVal; } + get sigma(): number { + return this.#sigma; + } + + set sigma(newVal: number) { + this.#sigma = newVal; + } + get agreementScoreThreshold(): number { return this.#agreementScoreThreshold; } @@ -58,6 +68,7 @@ export default class ConsensusSettings { iou_threshold: this.#iouThreshold, quorum: this.#quorum, agreement_score_threshold: this.#agreementScoreThreshold, + sigma: this.#sigma, }; return result; diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index 27621811d70..6cff4193904 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -263,6 +263,7 @@ export interface SerializedConsensusSettingsData { agreement_score_threshold?: number; quorum?: number; iou_threshold?: number; + sigma?: number; } export interface APIQualityConflictsFilter extends APICommonFilterParams { diff --git a/cvat-ui/src/components/consensus/consensus-settings-form.tsx b/cvat-ui/src/components/consensus/consensus-settings-form.tsx index d991e7b47da..cee50f847b6 100644 --- a/cvat-ui/src/components/consensus/consensus-settings-form.tsx +++ b/cvat-ui/src/components/consensus/consensus-settings-form.tsx @@ -38,6 +38,7 @@ export default function ConsensusSettingsForm(props: Props): JSX.Element | null iouThreshold: settings.iouThreshold * 100, agreementScoreThreshold: settings.agreementScoreThreshold * 100, quorum: settings.quorum, + sigma: settings.sigma * 100, }; const onSave = useCallback(async () => { @@ -48,6 +49,7 @@ export default function ConsensusSettingsForm(props: Props): JSX.Element | null settings.iouThreshold = values.iouThreshold / 100; settings.quorum = values.quorum; settings.agreementScoreThreshold = values.agreementScoreThreshold / 100; + settings.sigma = values.sigma / 100; try { const responseSettings = await settings.save(); @@ -82,6 +84,9 @@ export default function ConsensusSettingsForm(props: Props): JSX.Element | null Quorum is used for voting a label and attribute results to be counted + + Sigma is used while calculating the OKS distance + ); @@ -131,6 +136,15 @@ export default function ConsensusSettingsForm(props: Props): JSX.Element | null + + + + + diff --git a/cvat/apps/consensus/consensus_reports.py b/cvat/apps/consensus/consensus_reports.py index ba8bb3c7238..47e184314dd 100644 --- a/cvat/apps/consensus/consensus_reports.py +++ b/cvat/apps/consensus/consensus_reports.py @@ -194,6 +194,7 @@ class ComparisonParameters(_Serializable): agreement_score_threshold: float quorum: int iou_threshold: float + sigma: float def _value_serializer(self, v): if isinstance(v, dm.AnnotationType): diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index 1973e01e949..2c08b950afb 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -7,7 +7,8 @@ import datumaro as dm import django_rq -from datumaro.components.operations import IntersectMerge +# from datumaro.components.operations import IntersectMerge +from cvat.apps.consensus.new_intersect_merge import IntersectMerge from django.conf import settings from django.db import transaction from django.utils import timezone @@ -80,6 +81,7 @@ def _merge_consensus_jobs(task_id: int) -> None: pairwise_dist=consensus_settings.iou_threshold, output_conf_thresh=consensus_settings.agreement_score_threshold, quorum=consensus_settings.quorum, + sigma=consensus_settings.sigma, ) ) diff --git a/cvat/apps/consensus/migrations/0001_initial.py b/cvat/apps/consensus/migrations/0001_initial.py index be579661baa..6a981f7be47 100644 --- a/cvat/apps/consensus/migrations/0001_initial.py +++ b/cvat/apps/consensus/migrations/0001_initial.py @@ -1,7 +1,7 @@ -# Generated by Django 4.2.13 on 2024-07-18 18:20 +# Generated by Django 4.2.13 on 2024-07-28 09:18 -import django.db.models.deletion from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -9,7 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("engine", "0079_job_parent_job_id_and_more"), + ("engine", "0082_job_parent_job_id_and_more"), ] operations = [ @@ -25,6 +25,7 @@ class Migration(migrations.Migration): ("agreement_score_threshold", models.FloatField(default=0)), ("quorum", models.IntegerField(default=-1)), ("iou_threshold", models.FloatField(default=0.5)), + ("sigma", models.FloatField(default=0.1)), ( "task", models.ForeignKey( diff --git a/cvat/apps/consensus/models.py b/cvat/apps/consensus/models.py index a9f8279e05c..02772efb3d0 100644 --- a/cvat/apps/consensus/models.py +++ b/cvat/apps/consensus/models.py @@ -63,6 +63,7 @@ class ConsensusSettings(models.Model): agreement_score_threshold = models.FloatField(default=0) quorum = models.IntegerField(default=-1) iou_threshold = models.FloatField(default=0.5) + sigma = models.FloatField(default=0.1) def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) diff --git a/cvat/apps/consensus/serializers.py b/cvat/apps/consensus/serializers.py index 1ec74460a88..009a4c863d8 100644 --- a/cvat/apps/consensus/serializers.py +++ b/cvat/apps/consensus/serializers.py @@ -70,6 +70,7 @@ class Meta: "iou_threshold", "agreement_score_threshold", "quorum", + "sigma" ) read_only_fields = ( "id", @@ -86,6 +87,9 @@ class Meta: "quorum": """ Minimum count for a label and attribute voting results to be counted """, + "sigma": """ + Sigma value for OKS calculation + """, }.items(): extra_kwargs.setdefault(field_name, {}).setdefault( "help_text", textwrap.dedent(help_text.lstrip("\n")) @@ -100,5 +104,8 @@ def validate(self, attrs): # since we have constrained max. consensus jobs per regular job to 10 if not 0 <= v <= 10: raise serializers.ValidationError(f"{k} must be in the range [0; 10]") + elif k == "sigma": + if not 0.05 <= v <= 0.2: + raise serializers.ValidationError(f"{k} must be in the range [0.05; 0.2]") return super().validate(attrs) diff --git a/cvat/apps/engine/migrations/0079_job_parent_job_id_and_more.py b/cvat/apps/engine/migrations/0082_job_parent_job_id_and_more.py similarity index 89% rename from cvat/apps/engine/migrations/0079_job_parent_job_id_and_more.py rename to cvat/apps/engine/migrations/0082_job_parent_job_id_and_more.py index f42f949a689..04dcbd9fe50 100644 --- a/cvat/apps/engine/migrations/0079_job_parent_job_id_and_more.py +++ b/cvat/apps/engine/migrations/0082_job_parent_job_id_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.13 on 2024-07-18 17:56 +# Generated by Django 4.2.13 on 2024-07-28 08:52 import cvat.apps.engine.models from django.db import migrations, models @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ("engine", "0078_alter_cloudstorage_credentials"), + ("engine", "0081_job_assignee_updated_date_and_more"), ] operations = [ diff --git a/cvat/schema.yml b/cvat/schema.yml index 821ea049b50..b746abf7d5d 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -7768,6 +7768,11 @@ components: minimum: -2147483648 description: | Minimum count for a label and attribute voting results to be counted + sigma: + type: number + format: double + description: | + Sigma value for OKS calculation CredentialsTypeEnum: enum: - KEY_SECRET_KEY_PAIR @@ -8660,17 +8665,17 @@ components: allOf: - $ref: '#/components/schemas/Storage' nullable: true - assignee_updated_date: - type: string - format: date-time - readOnly: true - nullable: true parent_job_id: type: integer maximum: 2147483647 minimum: 0 nullable: true readOnly: true + assignee_updated_date: + type: string + format: date-time + readOnly: true + nullable: true required: - issues - labels @@ -9818,6 +9823,11 @@ components: minimum: -2147483648 description: | Minimum count for a label and attribute voting results to be counted + sigma: + type: number + format: double + description: | + Sigma value for OKS calculation PatchedDataMetaWriteRequest: type: object properties: From 1e607371c2d6c92863ce4791acc7ebd32c9f029c Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 28 Jul 2024 15:10:57 +0530 Subject: [PATCH 120/301] added matching from `quality_reports.py` and merging of skeletons to consensus --- cvat/apps/consensus/new_intersect_merge.py | 1673 ++++++++++---------- 1 file changed, 823 insertions(+), 850 deletions(-) diff --git a/cvat/apps/consensus/new_intersect_merge.py b/cvat/apps/consensus/new_intersect_merge.py index b6c01111a38..620ad87c081 100644 --- a/cvat/apps/consensus/new_intersect_merge.py +++ b/cvat/apps/consensus/new_intersect_merge.py @@ -3,15 +3,16 @@ # # SPDX-License-Identifier: MIT -import hashlib +import itertools import logging as log from collections import OrderedDict from copy import deepcopy -from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Type, Union -from unittest import TestCase +from functools import cached_property, partial +import math +from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Sequence, Tuple, Type, Union, cast import attr -import cv2 +import datumaro as dm import numpy as np from attr import attrib, attrs from datumaro.components.annotation import ( @@ -45,15 +46,47 @@ from datumaro.components.media import Image, MediaElement, MultiframeImage, PointCloud, Video from datumaro.util import filter_dict, find from datumaro.util.annotation_util import ( - OKS, approximate_line, bbox_iou, find_instances, max_bbox, mean_bbox, - segment_iou, ) from datumaro.util.attrs_util import default_if_none, ensure_cls +from scipy.optimize import linear_sum_assignment + + +def OKS(a, b, sigma=0.1, bbox=None, scale=None, visibility_a=None, visibility_b=None): + """ + Object Keypoint Similarity metric. + https://cocodataset.org/#keypoints-eval + """ + + p1 = np.array(a.points).reshape((-1, 2)) + p2 = np.array(b.points).reshape((-1, 2)) + if len(p1) != len(p2): + return 0 + + if visibility_a is None: + visibility_a = np.full(len(p1), True) + else: + visibility_a = np.asarray(visibility_a, dtype=bool) + + if visibility_b is None: + visibility_b = np.full(len(p2), True) + else: + visibility_b = np.asarray(visibility_b, dtype=bool) + + if not scale: + if bbox is None: + bbox = dm.ops.mean_bbox([a, b]) + scale = bbox[2] * bbox[3] + + dists = np.linalg.norm(p1 - p2, axis=1) + return np.sum( + visibility_a * visibility_b * np.exp((visibility_a == visibility_b)*(-(dists**2) / (2 * scale * (2 * sigma) ** 2))) + ) / np.sum(visibility_a | visibility_b, dtype=float) + def match_annotations_equal(a, b): @@ -390,12 +423,67 @@ def merge_media_types(sources: Iterable[IDataset]) -> Type[MediaElement]: return None +class _KeypointsMatcher(dm.ops.PointsMatcher): + def distance(self, a: dm.Points, b: dm.Points) -> float: + a_bbox = self.instance_map[id(a)][1] + b_bbox = self.instance_map[id(b)][1] + if dm.ops.bbox_iou(a_bbox, b_bbox) <= 0: + return 0 + + bbox = dm.ops.mean_bbox([a_bbox, b_bbox]) + return OKS( + a, + b, + sigma=self.sigma, + bbox=bbox, + visibility_a=[v == dm.Points.Visibility.visible for v in a.visibility], + visibility_b=[v == dm.Points.Visibility.visible for v in b.visibility], + ) + + +def _to_rle(ann: dm.Annotation, *, img_h: int, img_w: int): + from pycocotools import mask as mask_utils + + if ann.type == dm.AnnotationType.polygon: + return mask_utils.frPyObjects([ann.points], img_h, img_w) + elif isinstance(ann, dm.RleMask): + return [ann.rle] + elif ann.type == dm.AnnotationType.mask: + return [mask_utils.encode(ann.image)] + else: + assert False + + +def segment_iou(a: dm.Annotation, b: dm.Annotation, *, img_h: int, img_w: int) -> float: + """ + Generic IoU computation with masks and polygons. + Returns -1 if no intersection, [0; 1] otherwise + """ + # Comparing to the dm version, this fixes the comparison for segments, + # as the images size are required for correct decoding. + # Boxes are not included, because they are not needed + + from pycocotools import mask as mask_utils + + is_bbox = AnnotationType.bbox in [a.type, b.type] + + if not is_bbox: + a = _to_rle(a, img_h=img_h, img_w=img_w) + b = _to_rle(b, img_h=img_h, img_w=img_w) + else: + a = [list(a.get_bbox())] + b = [list(b.get_bbox())] + + # Note that mask_utils.iou expects (dt, gt). Check this if the 3rd param is True + return float(mask_utils.iou(b, a, [0])) + + @attrs class IntersectMerge(MergingStrategy): @attrs(repr_ns="IntersectMerge", kw_only=True) class Conf: pairwise_dist = attrib(converter=float, default=0.5) - sigma = attrib(converter=list, factory=list) + sigma = attrib(converter=float, factory=float) output_conf_thresh = attrib(converter=float, default=0) quorum = attrib(converter=int, default=0) @@ -463,8 +551,8 @@ def get_ann_source(self, ann_id): def merge_items(self, items): self._item = next(iter(items.values())) - self._ann_map = {} - sources = [] + self._ann_map = {} # id(annotation) -> (annotation, id(frame/item)) + sources = [] # [annotation of frame 0, frame 1, ...] for item in items.values(): self._ann_map.update({id(a): (a, id(item)) for a in item.annotations}) sources.append(item.annotations) @@ -668,7 +756,7 @@ def _merge_categories(self, sources): return dst_categories def _match_annotations(self, sources): - all_by_type = {} + all_by_type = {} # annotation type -> [[annotations from frame 0], [frame 1]] for s in sources: src_by_type = {} for a in s: @@ -695,25 +783,17 @@ def _for_type(t, **kwargs): return _make(BboxMerger, **kwargs) elif t is AnnotationType.mask: return _make(MaskMerger, **kwargs) - elif t is AnnotationType.polygon: + elif t is AnnotationType.polygon or AnnotationType.mask: return _make(PolygonMerger, **kwargs) elif t is AnnotationType.polyline: return _make(LineMerger, **kwargs) elif t is AnnotationType.points: return _make(PointsMerger, **kwargs) - elif t is AnnotationType.caption: - return _make(CaptionsMerger, **kwargs) - elif t is AnnotationType.cuboid_3d: - return _make(Cuboid3dMerger, **kwargs) - elif t is AnnotationType.super_resolution_annotation: - return _make(ImageAnnotationMerger, **kwargs) - elif t is AnnotationType.depth_annotation: - return _make(ImageAnnotationMerger, **kwargs) elif t is AnnotationType.skeleton: - # to do: add skeletons merge - return _make(ImageAnnotationMerger, **kwargs) - else: - raise NotImplementedError("Type %s is not supported" % t) + return _make(SkeletonMerger, **kwargs) + # else: + # pass + # raise NotImplementedError("Type %s is not supported" % t) instance_map = {} for s in sources: @@ -734,7 +814,7 @@ def _for_type(t, **kwargs): for ann in inst: instance_map[id(ann)] = [inst, inst_bbox] - self._mergers = {t: _for_type(t, instance_map=instance_map) for t in AnnotationType} + self._mergers = {t: _for_type(t, instance_map=instance_map, categories=self._categories) for t in AnnotationType} def _match_ann_type(self, t, sources): return self._mergers[t].match_annotations(sources) @@ -936,10 +1016,103 @@ def match_annotations(self, sources): class _ShapeMatcher(AnnotationMatcher): pairwise_dist = attrib(converter=float, default=0.9) cluster_dist = attrib(converter=float, default=-1.0) + return_distances = False + categories = attrib(converter=dict, default={}) + + def _instance_bbox( + self, instance_anns: Sequence[dm.Annotation] + ) -> Tuple[float, float, float, float]: + return dm.ops.max_bbox( + a.get_bbox() if isinstance(a, dm.Skeleton) else a + for a in instance_anns + if hasattr(a, "get_bbox") and not a.attributes.get("outside", False) + ) + + def distance(a, b): + return segment_iou(a, b) + + def label_matcher(self, a, b): + a_label = self._context._get_any_label_name(a, a.label) + b_label = self._context._get_any_label_name(b, b.label) + return a_label == b_label + + @classmethod + def _make_memoizing_distance(cls, distance_function: Callable[[Any, Any], float]): + distances = {} + notfound = object() + + def memoizing_distance(a, b): + if isinstance(a, int) and isinstance(b, int): + key = (a, b) + else: + key = (id(a), id(b)) + + dist = distances.get(key, notfound) + + if dist is notfound: + dist = distance_function(a, b) + distances[key] = dist + + return dist + + return memoizing_distance, distances + + @staticmethod + def _get_ann_type(t, item: dm.Annotation) -> Sequence[dm.Annotation]: + return [ + a for a in item if a.type == t and not a.attributes.get("outside", False) + ] + + def _match_segments( + self, + t, + item_a, + item_b, + *, + distance: Callable = distance, + label_matcher: Callable = None, + a_objs: Optional[Sequence[dm.Annotation]] = None, + b_objs: Optional[Sequence[dm.Annotation]] = None, + dist_thresh: Optional[float] = None, + ): + if label_matcher is None: + label_matcher = self.label_matcher + if dist_thresh is None: + dist_thresh = self.pairwise_dist + if a_objs is None: + a_objs = self._get_ann_type(t, item_a) + if b_objs is None: + b_objs = self._get_ann_type(t, item_b) + + if self.return_distances: + distance, distances = self._make_memoizing_distance(distance) + + if not a_objs and not b_objs: + distances = {} + returned_values = [], [], [], [] + else: + extra_args = {} + if label_matcher: + extra_args["label_matcher"] = label_matcher + + returned_values = match_segments( + a_objs, + b_objs, + distance=distance, + dist_thresh=dist_thresh if dist_thresh is not None else self.iou_threshold, + **extra_args, + ) + + if self.return_distances: + returned_values = returned_values + (distances,) + + return returned_values + + def match_annotations_two_sources(self, a, b): + pass def match_annotations(self, sources): distance = self.distance - label_matcher = self.label_matcher pairwise_dist = self.pairwise_dist cluster_dist = self.cluster_dist @@ -969,14 +1142,19 @@ def _has_same_source(cluster, extra_id): # match segments in sources, pairwise adjacent = {i: [] for i in id_segm} # id(sgm) -> [id(adj_sgm1), ...] for a_idx, src_a in enumerate(sources): + # matches further sources of same frame for matching annotations for src_b in sources[a_idx + 1 :]: - matches, _, _, _ = match_segments( - src_a, - src_b, - dist_thresh=pairwise_dist, - distance=distance, - label_matcher=label_matcher, - ) + # an annotation can be adjacent to multiple annotations + if self.return_distances: + matches, _, _, _, _ = self.match_annotations_two_sources( + src_a, + src_b, + ) + else: + matches, _, _, _ = self.match_annotations_two_sources( + src_a, + src_b, + ) for a, b in matches: adjacent[id(a)].append(id(b)) @@ -996,35 +1174,229 @@ def _has_same_source(cluster, extra_id): for i in adjacent[c]: if i in visited: + # if that annotation is already in another cluster continue if 0 < cluster_dist and not _is_close_enough(cluster, i): + # if positive cluster_dist and this annotation isn't close enough with other annotations in cluster continue if _has_same_source(cluster, i): + # if both the annotation are belong to the same frame in same consensus job continue - to_visit.add(i) + to_visit.add( + i + ) # check whether annotations matching this element in cluster can be added in this cluster clusters.append([id_segm[i][0] for i in cluster]) return clusters - def distance(self, a, b): - return segment_iou(a, b) - - def label_matcher(self, a, b): - a_label = self._context._get_any_label_name(a, a.label) - b_label = self._context._get_any_label_name(b, b.label) - return a_label == b_label - @attrs class BboxMatcher(_ShapeMatcher): - pass + def distance(self, a, b): + def _to_polygon(bbox_ann: dm.Bbox): + points = bbox_ann.as_polygon() + angle = bbox_ann.attributes.get("rotation", 0) / 180 * math.pi + + if angle: + points = np.reshape(points, (-1, 2)) + center = (points[0] + points[2]) / 2 + rel_points = points - center + cos = np.cos(angle) + sin = np.sin(angle) + rotation_matrix = ((cos, sin), (-sin, cos)) + points = np.matmul(rel_points, rotation_matrix) + center + points = points.flatten() + + return dm.Polygon(points) + + def _bbox_iou(a: dm.Bbox, b: dm.Bbox, *, img_w: int, img_h: int) -> float: + if a.attributes.get("rotation", 0) == b.attributes.get("rotation", 0): + return dm.ops.bbox_iou(a, b) + else: + return segment_iou(_to_polygon(a), _to_polygon(b), img_h=img_h, img_w=img_w) + dataitem_id = self._context._ann_map[id(a)][1] + img_h, img_w = self._context._item_map[dataitem_id][0].image.size + return _bbox_iou(a, b, img_h=img_h, img_w=img_w) + + def match_annotations_two_sources(self, a, b): + return self._match_segments( + dm.AnnotationType.bbox, + a, + b, + distance=self.distance, + ) @attrs class PolygonMatcher(_ShapeMatcher): - pass + def distance(self, item_a, item_b): + from pycocotools import mask as mask_utils + + def _get_segment(item): + dataitem_id = self._context._ann_map[id(item)][1] + img_h, img_w = self._context._item_map[dataitem_id][0].image.size + object_rle_groups = [_to_rle(item, img_h=img_h, img_w=img_w)] + rle = mask_utils.merge(list(itertools.chain.from_iterable(object_rle_groups))) + return rle + + a_segm = _get_segment(item_a) + b_segm = _get_segment(item_b) + return float(mask_utils.iou([b_segm], [a_segm], [0])[0]) + + def match_annotations_two_sources(self, item_a, item_b): + def _get_segmentations(item): + return self._get_ann_type(dm.AnnotationType.polygon, item) + self._get_ann_type( + dm.AnnotationType.mask, item + ) + + dataitem_id = self._context._ann_map[id(item_a[0])][1] + img_h, img_w = self._context._item_map[dataitem_id][0].image.size + + def _find_instances(annotations): + # Group instance annotations by label. + # Annotations with the same label and group will be merged, + # and considered a single object in comparison + instances = [] + instance_map = {} # ann id -> instance id + for ann_group in dm.ops.find_instances(annotations): + ann_group = sorted(ann_group, key=lambda a: a.label) + for _, label_group in itertools.groupby(ann_group, key=lambda a: a.label): + label_group = list(label_group) + instance_id = len(instances) + instances.append(label_group) + for ann in label_group: + instance_map[id(ann)] = instance_id + + return instances, instance_map + + def _get_compiled_mask( + anns: Sequence[dm.Annotation], *, instance_ids: Dict[int, int] + ) -> dm.CompiledMask: + if not anns: + return None + + from pycocotools import mask as mask_utils + + object_rle_groups = [_to_rle(ann, img_h=img_h, img_w=img_w) for ann in anns] + object_rles = [mask_utils.merge(g) for g in object_rle_groups] + object_masks = mask_utils.decode(object_rles) + + return dm.CompiledMask.from_instance_masks( + # need to increment labels and instance ids by 1 to avoid confusion with background + instance_masks=( + dm.Mask(image=object_masks[:, :, i], z_order=ann.z_order, label=ann.label + 1) + for i, ann in enumerate(anns) + ), + instance_ids=(iid + 1 for iid in instance_ids), + ) + + a_instances, a_instance_map = _find_instances(_get_segmentations(item_a)) + b_instances, b_instance_map = _find_instances(_get_segmentations(item_b)) + + # if self.panoptic_comparison: + # a_compiled_mask = _get_compiled_mask( + # list(itertools.chain.from_iterable(a_instances)), + # instance_ids=[ + # a_instance_map[id(ann)] for ann in itertools.chain.from_iterable(a_instances) + # ], + # ) + # b_compiled_mask = _get_compiled_mask( + # list(itertools.chain.from_iterable(b_instances)), + # instance_ids=[ + # b_instance_map[id(ann)] for ann in itertools.chain.from_iterable(b_instances) + # ], + # ) + # else: + a_compiled_mask = None + b_compiled_mask = None + + segment_cache = {} + + def _get_segment( + obj_id: int, *, compiled_mask: Optional[dm.CompiledMask] = None, instances + ): + key = (id(instances), obj_id) + rle = segment_cache.get(key) + + if rle is None: + from pycocotools import mask as mask_utils + + if compiled_mask is not None: + mask = compiled_mask.extract(obj_id + 1) + + rle = mask_utils.encode(mask) + else: + # Create merged RLE for the instance shapes + object_anns = instances[obj_id] + object_rle_groups = [ + _to_rle(ann, img_h=img_h, img_w=img_w) for ann in object_anns + ] + rle = mask_utils.merge(list(itertools.chain.from_iterable(object_rle_groups))) + + segment_cache[key] = rle + + return rle + + def _segment_comparator(a_inst_id: int, b_inst_id: int) -> float: + a_segm = _get_segment(a_inst_id, compiled_mask=a_compiled_mask, instances=a_instances) + b_segm = _get_segment(b_inst_id, compiled_mask=b_compiled_mask, instances=b_instances) + + from pycocotools import mask as mask_utils + + return float(mask_utils.iou([b_segm], [a_segm], [0])[0]) + + def _label_matcher(a_inst_id: int, b_inst_id: int) -> bool: + # labels are the same in the instance annotations + # instances are required to have the same labels in all shapes + a = a_instances[a_inst_id][0] + b = b_instances[b_inst_id][0] + return a.label == b.label + + results = self._match_segments( + dm.AnnotationType.polygon, + item_a, + item_b, + a_objs=range(len(a_instances)), + b_objs=range(len(b_instances)), + distance=_segment_comparator, + label_matcher=_label_matcher, + ) + + # restore results for original annotations + matched, mismatched, a_extra, b_extra = results[:4] + if self.return_distances: + distances = results[4] + + # i_x ~ instance idx in _x + # ia_x ~ instance annotation in _x + matched = [ + (ia_a, ia_b) + for (i_a, i_b) in matched + for (ia_a, ia_b) in itertools.product(a_instances[i_a], b_instances[i_b]) + ] + mismatched = [ + (ia_a, ia_b) + for (i_a, i_b) in mismatched + for (ia_a, ia_b) in itertools.product(a_instances[i_a], b_instances[i_b]) + ] + a_extra = [ia_a for i_a in a_extra for ia_a in a_instances[i_a]] + b_extra = [ia_b for i_b in b_extra for ia_b in b_instances[i_b]] + + if self.return_distances: + for i_a, i_b in list(distances.keys()): + dist = distances.pop((i_a, i_b)) + + for ia_a, ia_b in itertools.product(a_instances[i_a], b_instances[i_b]): + distances[(id(ia_a), id(ia_b))] = dist + + returned_values = (matched, mismatched, a_extra, b_extra) + + if self.return_distances: + returned_values = returned_values + (distances,) + + return returned_values @attrs @@ -1038,86 +1410,363 @@ class PointsMatcher(_ShapeMatcher): instance_map = attrib(converter=dict) def distance(self, a, b): + for source_anns in [a.annotations, b.annotations]: + source_instances = dm.ops.find_instances(source_anns) + for instance_group in source_instances: + instance_bbox = self._instance_bbox(instance_group) + + for ann in instance_group: + if ann.type == dm.AnnotationType.points: + self.instance_map[id(ann)] = [instance_group, instance_bbox] + + dataitem_id = self._context._ann_map[id(a)][1] + img_h, img_w = self._context._item_map[dataitem_id][0].image.size a_bbox = self.instance_map[id(a)][1] b_bbox = self.instance_map[id(b)][1] - if bbox_iou(a_bbox, b_bbox) <= 0: - return 0 - bbox = mean_bbox([a_bbox, b_bbox]) - return OKS(a, b, sigma=self.sigma, bbox=bbox) + a_area = a_bbox[2] * a_bbox[3] + b_area = b_bbox[2] * b_bbox[3] + if a_area == 0 and b_area == 0: + # Simple case: singular points without bbox + # match them in the image space + return OKS(a, b, sigma=self.sigma, scale=img_h * img_w) -@attrs -class LineMatcher(_ShapeMatcher): - def distance(self, a, b): - # Compute inter-line area by using the Trapezoid formulae - # https://en.wikipedia.org/wiki/Trapezoidal_rule - # Normalize by common bbox and get the bbox fill ratio - # Call this ratio the "distance" - - # The box area is an early-exit filter for non-intersected figures - bbox = max_bbox([a, b]) - box_area = bbox[2] * bbox[3] - if not box_area: - return 1 - - def _approx(line, segments): - if len(line) // 2 != segments + 1: - line = approximate_line(line, segments=segments) - return np.reshape(line, (-1, 2)) - - segments = max(len(a.points) // 2, len(b.points) // 2, 5) - 1 - - a = _approx(a.points, segments) - b = _approx(b.points, segments) - dists = np.linalg.norm(a - b, axis=1) - dists = dists[:-1] + dists[1:] - a_steps = np.linalg.norm(a[1:] - a[:-1], axis=1) - b_steps = np.linalg.norm(b[1:] - b[:-1], axis=1) + else: + # Complex case: multiple points, grouped points, points with a bbox + # Try to align points and then return the metric + # match them in their bbox space + + if dm.ops.bbox_iou(a_bbox, b_bbox) <= 0: + return 0 + + bbox = dm.ops.mean_bbox([a_bbox, b_bbox]) + scale = bbox[2] * bbox[3] + + a_points = np.reshape(a.points, (-1, 2)) + b_points = np.reshape(b.points, (-1, 2)) + + matches, mismatches, a_extra, b_extra = match_segments( + range(len(a_points)), + range(len(b_points)), + distance=lambda ai, bi: OKS( + dm.Points(a_points[ai]), + dm.Points(b_points[bi]), + sigma=self.sigma, + scale=scale, + ), + dist_thresh=self.iou_threshold, + label_matcher=lambda ai, bi: True, + ) - # For the common bbox we can't use - # - the AABB (axis-alinged bbox) of a point set - # - the exterior of a point set - # - the convex hull of a point set - # because these soultions won't be correctly normalized. - # The lines can have multiple self-intersections, which can give - # the inter-line area more than internal area of the options above, - # producing the value of the distance outside of the [0; 1] range. - # - # Instead, we can compute the upper boundary for the inter-line - # area based on the maximum point distance and line length. - max_area = np.max(dists) * max(np.sum(a_steps), np.sum(b_steps)) + # the exact array is determined by the label matcher + # all the points will have the same match status, + # because there is only 1 shared label for all the points + matched_points = matches + mismatches - area = np.dot(dists, a_steps + b_steps) * 0.5 * 0.5 / max(max_area, 1.0) + a_sorting_indices = [ai for ai, _ in matched_points] + a_points = a_points[a_sorting_indices] - return abs(1 - area) + b_sorting_indices = [bi for _, bi in matched_points] + b_points = b_points[b_sorting_indices] + # Compute OKS for 2 groups of points, matching points aligned + dists = np.linalg.norm(a_points - b_points, axis=1) + return np.sum(np.exp(-(dists**2) / (2 * scale * (2 * self.sigma) ** 2))) / ( + len(matched_points) + len(a_extra) + len(b_extra) + ) -@attrs -class CaptionsMatcher(AnnotationMatcher): - def match_annotations(self, sources): - raise NotImplementedError() + def match_annotations_two_sources(self, item_a, item_b): + a_points = self._get_ann_type(dm.AnnotationType.points, item_a) + b_points = self._get_ann_type(dm.AnnotationType.points, item_b) + + return self._match_segments( + dm.AnnotationType.points, + item_a, + item_b, + a_objs=a_points, + b_objs=b_points, + distance=self.distance, + ) -@attrs -class Cuboid3dMatcher(_ShapeMatcher): +class SkeletonMatcher(_ShapeMatcher): + return_distances = True + sigma: float = 0.1 + instance_map = {} + skeleton_map = {} + _skeleton_info = {} + distances = {} + def distance(self, a, b): - raise NotImplementedError() + matcher = _KeypointsMatcher(instance_map=self.instance_map, sigma=self.sigma) + if isinstance(a, dm.Skeleton) and isinstance(b, dm.Skeleton): + if a == b: + return 1 + elif (id(b), id(a)) in self.distances: + return self.distances[(id(b), id(a))] + else: + return self.distances[(id(a), id(b))] + + return matcher.distance(a, b) + + def _get_skeleton_info(self, skeleton_label_id: int): + label_cat = cast(dm.LabelCategories, self.categories[dm.AnnotationType.label]) + skeleton_info = self._skeleton_info.get(skeleton_label_id) + + if skeleton_info is None: + skeleton_label_name = label_cat[skeleton_label_id].name + + # Build a sorted list of sublabels to arrange skeleton points during comparison + skeleton_info = sorted( + idx for idx, label in enumerate(label_cat) if label.parent == skeleton_label_name + ) + self._skeleton_info[skeleton_label_id] = skeleton_info + + return skeleton_info + + def match_annotations_two_sources(self, a_skeletons, b_skeletons): + if not a_skeletons and not b_skeletons: + return [], [], [], [] + + # Convert skeletons to point lists for comparison + # This is required to compute correct per-instance distance + # It is assumed that labels are the same in the datasets + skeleton_infos = {} + points_map = {} + a_points = [] + b_points = [] + for source, source_points in [(a_skeletons, a_points), (b_skeletons, b_points)]: + for skeleton in source: + skeleton_info = skeleton_infos.setdefault( + skeleton.label, self._get_skeleton_info(skeleton.label) + ) + + # Merge skeleton points into a single list + # The list is ordered by skeleton_info + skeleton_points = [ + next((p for p in skeleton.elements if p.label == sublabel), None) + for sublabel in skeleton_info + ] + + # Build a single Points object for further comparisons + merged_points = dm.Points() + merged_points.points = np.ravel( + [p.points if p else [0, 0] for p in skeleton_points] + ) + merged_points.visibility = np.ravel( + [p.visibility if p else [dm.Points.Visibility.absent] for p in skeleton_points] + ) + merged_points.label = skeleton.label + # no per-point attributes currently in CVAT + + if all(v == dm.Points.Visibility.absent for v in merged_points.visibility): + # The whole skeleton is outside, exclude it + self.skeleton_map[id(skeleton)] = None + continue + + points_map[id(merged_points)] = skeleton + self.skeleton_map[id(skeleton)] = merged_points + source_points.append(merged_points) + + for source in [a_skeletons, b_skeletons]: + for instance_group in dm.ops.find_instances(source): + instance_bbox = self._instance_bbox(instance_group) + + instance_group = [ + self.skeleton_map[id(a)] if isinstance(a, dm.Skeleton) else a + for a in instance_group + if not isinstance(a, dm.Skeleton) or self.skeleton_map[id(a)] is not None + ] + for ann in instance_group: + self.instance_map[id(ann)] = [instance_group, instance_bbox] + + results = self._match_segments( + dm.AnnotationType.points, + a_skeletons, + b_skeletons, + a_objs=a_points, + b_objs=b_points, + distance=self.distance, + ) + + matched, mismatched, a_extra, b_extra = results[:4] + if self.return_distances: + distances = results[4] + + matched = [(points_map[id(p_a)], points_map[id(p_b)]) for (p_a, p_b) in matched] + mismatched = [(points_map[id(p_a)], points_map[id(p_b)]) for (p_a, p_b) in mismatched] + a_extra = [points_map[id(p_a)] for p_a in a_extra] + b_extra = [points_map[id(p_b)] for p_b in b_extra] + + # Map points back to skeletons + if self.return_distances: + for p_a_id, p_b_id in list(distances.keys()): + dist = distances.pop((p_a_id, p_b_id)) + distances[(id(points_map[p_a_id]), id(points_map[p_b_id]))] = dist + + self.distances.update(distances) + returned_values = (matched, mismatched, a_extra, b_extra) + + if self.return_distances: + returned_values = returned_values + (distances,) + + return returned_values @attrs -class ImageAnnotationMatcher(AnnotationMatcher): - def match_annotations(self, sources): - raise NotImplementedError() +class LineMatcher(_ShapeMatcher): + EPSILON = 1e-7 + torso_r: float = 0.01 + oriented: bool = True + scale: float = None + + def distance(self, a: dm.PolyLine, b: dm.PolyLine) -> float: + # Check distances of the very coarse estimates for the curves + def _get_bbox_circle(ann: dm.PolyLine): + xs = ann.points[0::2] + ys = ann.points[1::2] + x0 = min(xs) + x1 = max(xs) + y0 = min(ys) + y1 = max(ys) + return (x0 + x1) / 2, (y0 + y1) / 2, ((x1 - x0) ** 2 + (y1 - y0) ** 2) / 4 + + a_center_x, a_center_y, a_r2 = _get_bbox_circle(a) + b_center_x, b_center_y, b_r2 = _get_bbox_circle(b) + sigma6_2 = self.scale * (6 * self.torso_r) ** 2 + if ( + (b_center_x - a_center_x) ** 2 + (b_center_y - a_center_y) ** 2 + ) > b_r2 + a_r2 + sigma6_2: + return 0 + # Approximate lines to the same number of points for pointwise comparison + a, b = self.approximate_points( + np.array(a.points).reshape((-1, 2)), np.array(b.points).reshape((-1, 2)) + ) -@attrs(kw_only=True) -class AnnotationMerger: - def merge_clusters(self, clusters): - raise NotImplementedError() + # Compare the direct and, optionally, the reverse variants + similarities = [] + candidates = [b] + if not self.oriented: + candidates.append(b[::-1]) + + for candidate_b in candidates: + similarities.append(self._compare_lines(a, candidate_b)) + + return max(similarities) + + def _compare_lines(self, a: np.ndarray, b: np.ndarray) -> float: + dists = np.linalg.norm(a - b, axis=1) + + scale = self.scale + if scale is None: + segment_dists = np.linalg.norm(a[1:] - a[:-1], axis=1) + scale = np.sum(segment_dists) ** 2 + + # Compute Gaussian for approximated lines similarly to OKS + return sum(np.exp(-(dists**2) / (2 * scale * (2 * self.torso_r) ** 2))) / len(a) + + @classmethod + def approximate_points(cls, a: np.ndarray, b: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """ + Creates 2 polylines with the same numbers of points, + the points are placed on the original lines with the same step. + The step for each point is determined as minimal to the next original + point on one of the curves. + A simpler, but slower version could be just approximate each curve to + some big number of points. The advantage of this algo is that it keeps + corners and original points untouched, while adding intermediate points. + """ + a_segment_count = len(a) - 1 + b_segment_count = len(b) - 1 + + a_segment_lengths = np.linalg.norm(a[1:] - a[:-1], axis=1) + a_segment_end_dists = [0] + for l in a_segment_lengths: + a_segment_end_dists.append(a_segment_end_dists[-1] + l) + a_segment_end_dists.pop(0) + a_segment_end_dists.append(a_segment_end_dists[-1]) # duplicate for simpler code + + b_segment_lengths = np.linalg.norm(b[1:] - b[:-1], axis=1) + b_segment_end_dists = [0] + for l in b_segment_lengths: + b_segment_end_dists.append(b_segment_end_dists[-1] + l) + b_segment_end_dists.pop(0) + b_segment_end_dists.append(b_segment_end_dists[-1]) # duplicate for simpler code + + a_length = a_segment_end_dists[-1] + b_length = b_segment_end_dists[-1] + + # lines can have lesser number of points in some cases + max_points_count = len(a) + len(b) - 1 + a_new_points = np.zeros((max_points_count, 2)) + b_new_points = np.zeros((max_points_count, 2)) + a_new_points[0] = a[0] + b_new_points[0] = b[0] + + a_segment_idx = 0 + b_segment_idx = 0 + while a_segment_idx < a_segment_count or b_segment_idx < b_segment_count: + next_point_idx = a_segment_idx + b_segment_idx + 1 + + a_segment_end_pos = a_segment_end_dists[a_segment_idx] / (a_length or 1) + b_segment_end_pos = b_segment_end_dists[b_segment_idx] / (b_length or 1) + if a_segment_idx < a_segment_count and a_segment_end_pos <= b_segment_end_pos: + if b_segment_idx < b_segment_count: + # advance b in the current segment to the relative position in a + q = (b_segment_end_pos - a_segment_end_pos) * ( + b_length / (b_segment_lengths[b_segment_idx] or 1) + ) + if abs(q) <= cls.EPSILON: + b_new_points[next_point_idx] = b[1 + b_segment_idx] + else: + b_new_points[next_point_idx] = b[b_segment_idx] * q + b[ + 1 + b_segment_idx + ] * (1 - q) + elif b_segment_idx == b_segment_count: + b_new_points[next_point_idx] = b[b_segment_idx] + + # advance a to the end of the current segment + a_segment_idx += 1 + a_new_points[next_point_idx] = a[a_segment_idx] + + elif b_segment_idx < b_segment_count: + if a_segment_idx < a_segment_count: + # advance a in the current segment to the relative position in b + q = (a_segment_end_pos - b_segment_end_pos) * ( + a_length / (a_segment_lengths[a_segment_idx] or 1) + ) + if abs(q) <= cls.EPSILON: + a_new_points[next_point_idx] = a[1 + a_segment_idx] + else: + a_new_points[next_point_idx] = a[a_segment_idx] * q + a[ + 1 + a_segment_idx + ] * (1 - q) + elif a_segment_idx == a_segment_count: + a_new_points[next_point_idx] = a[a_segment_idx] + + # advance b to the end of the current segment + b_segment_idx += 1 + b_new_points[next_point_idx] = b[b_segment_idx] + + else: + assert False + + # truncate the final values + if next_point_idx < max_points_count: + a_new_points = a_new_points[:next_point_idx] + b_new_points = b_new_points[:next_point_idx] + + return a_new_points, b_new_points + + def match_annotations_two_sources(self, item_a, item_b): + return self._match_segments( + dm.AnnotationType.polyline, item_a, item_b, distance=self.distance + ) @attrs(kw_only=True) -class LabelMerger(AnnotationMerger, LabelMatcher): +class LabelMerger(LabelMatcher): quorum = attrib(converter=int, default=0) def merge_clusters(self, clusters): @@ -1153,7 +1802,7 @@ def merge_clusters(self, clusters): @attrs(kw_only=True) -class _ShapeMerger(AnnotationMerger, _ShapeMatcher): +class _ShapeMerger(_ShapeMatcher): quorum = attrib(converter=int, default=0) def merge_clusters(self, clusters): @@ -1175,10 +1824,12 @@ def find_cluster_label(self, cluster): label = self._context._get_label_id(label) return label, score - @staticmethod - def _merge_cluster_shape_mean_box_nearest(cluster): + def _merge_cluster_shape_mean_box_nearest(self, cluster): mbbox = Bbox(*mean_bbox(cluster)) - dist = list(segment_iou(mbbox, s) for s in cluster) + a = cluster[0] + dataitem_id = self._context._ann_map[id(a)][1] + img_h, img_w = self._context._item_map[dataitem_id][0].image.size + dist = list(segment_iou(mbbox, s, img_h=img_h, img_w=img_w) for s in cluster) # print(cluster) # print(mbbox, dist) nearest_pos, _ = max(enumerate(dist), key=lambda e: e[1]) @@ -1191,14 +1842,13 @@ def merge_cluster_shape(self, cluster): return shape, shape_score def merge_cluster(self, cluster): - for ann in cluster: - if ann.id == 3: - print(cluster) label, label_score = self.find_cluster_label(cluster) + + # when the merged annotation is rejected due to quorum constraint + if label is None: + return None + shape, shape_score = self.merge_cluster_shape(cluster) - # print(shape, shape_score, label, label_score) - # if label is None: - # return None shape.z_order = max(cluster, key=lambda a: a.z_order).z_order shape.label = label shape.attributes["score"] = label_score * shape_score if label is not None else shape_score @@ -1230,767 +1880,90 @@ class PointsMerger(_ShapeMerger, PointsMatcher): class LineMerger(_ShapeMerger, LineMatcher): pass +class SkeletonMerger(_ShapeMerger, SkeletonMatcher): + def _merge_cluster_shape_nearest(self, cluster): + dist = {} + for idx, skeleton1 in enumerate(cluster): + id_skeleton1 = id(skeleton1) + skeleton_distance = 0 + for skeleton2 in cluster: + id_skeleton2 = id(skeleton2) + if (id_skeleton1, id_skeleton2) in self.distances: + skeleton_distance += self.distances[(id_skeleton1, id_skeleton2)] + elif (id_skeleton2, id_skeleton1) in self.distances: + skeleton_distance += self.distances[(id_skeleton2, id_skeleton1)] + else: + skeleton_distance += 1 -@attrs -class CaptionsMerger(AnnotationMerger, CaptionsMatcher): - pass - - -@attrs -class Cuboid3dMerger(_ShapeMerger, Cuboid3dMatcher): - @staticmethod - def _merge_cluster_shape_mean_box_nearest(cluster): - raise NotImplementedError() - # mbbox = Bbox(*mean_cuboid(cluster)) - # dist = (segment_iou(mbbox, s) for s in cluster) - # nearest_pos, _ = max(enumerate(dist), key=lambda e: e[1]) - # return cluster[nearest_pos] + dist[idx] = skeleton_distance / len(cluster) - def merge_cluster(self, cluster): - label, label_score = self.find_cluster_label(cluster) - shape, shape_score = self.merge_cluster_shape(cluster) + return cluster[min(dist, key=dist.get)] - shape.label = label - shape.attributes["score"] = label_score * shape_score if label is not None else shape_score - - return shape + def merge_cluster_shape(self, cluster): + shape = self._merge_cluster_shape_nearest(cluster) + shape_score = sum(max(0, self.distance(shape, s)) for s in cluster) / len(cluster) + return shape, shape_score + # def __init__(self, categories=None): + # _ShapeMerger.__init__(self) + # SkeletonMatcher.__init__(self, categories) -@attrs -class ImageAnnotationMerger(AnnotationMerger, ImageAnnotationMatcher): - pass def match_segments( a_segms, b_segms, - distance=segment_iou, + distance=dm.ops.segment_iou, dist_thresh=1.0, label_matcher=lambda a, b: a.label == b.label, ): assert callable(distance), distance assert callable(label_matcher), label_matcher - a_segms.sort(key=lambda ann: 1 - ann.attributes.get("score", 1)) - b_segms.sort(key=lambda ann: 1 - ann.attributes.get("score", 1)) - - # a_matches: indices of b_segms matched to a bboxes - # b_matches: indices of a_segms matched to b bboxes - a_matches = -np.ones(len(a_segms), dtype=int) - b_matches = -np.ones(len(b_segms), dtype=int) + max_anns = max(len(a_segms), len(b_segms)) + distances = np.array( + [ + [ + 1 - distance(a, b) if a is not None and b is not None else 1 + for b, _ in itertools.zip_longest(b_segms, range(max_anns), fillvalue=None) + ] + for a, _ in itertools.zip_longest(a_segms, range(max_anns), fillvalue=None) + ] + ) + distances[~np.isfinite(distances)] = 1 + distances[distances > 1 - dist_thresh] = 1 - distances = np.array([[distance(a, b) for b in b_segms] for a in a_segms]) + if a_segms and b_segms: + a_matches, b_matches = linear_sum_assignment(distances) + else: + a_matches = [] + b_matches = [] # matches: boxes we succeeded to match completely # mispred: boxes we succeeded to match, having label mismatch matches = [] mispred = [] - - for a_idx, a_segm in enumerate(a_segms): - if len(b_segms) == 0: - break - matched_b = -1 - max_dist = -1 - b_indices = np.argsort( - [not label_matcher(a_segm, b_segm) for b_segm in b_segms], kind="stable" - ) # prioritize those with same label, keep score order - for b_idx in b_indices: - if 0 <= b_matches[b_idx]: # assign a_segm with max conf - continue - d = distances[a_idx, b_idx] - if d < dist_thresh or d <= max_dist: - continue - max_dist = d - matched_b = b_idx - - if matched_b < 0: - continue - a_matches[a_idx] = matched_b - b_matches[matched_b] = a_idx - - b_segm = b_segms[matched_b] - - if label_matcher(a_segm, b_segm): - matches.append((a_segm, b_segm)) - else: - mispred.append((a_segm, b_segm)) - # *_umatched: boxes of (*) we failed to match - a_unmatched = [a_segms[i] for i, m in enumerate(a_matches) if m < 0] - b_unmatched = [b_segms[i] for i, m in enumerate(b_matches) if m < 0] - - return matches, mispred, a_unmatched, b_unmatched - - -def mean_std(dataset: IDataset): - counter = _MeanStdCounter() - - for item in dataset: - counter.accumulate(item) - - return counter.get_result() - - -class _MeanStdCounter: - """ - Computes unbiased mean and std. dev. for dataset images, channel-wise. - """ - - def __init__(self): - self._stats = {} # (id, subset) -> (pixel count, mean vec, std vec) - - def accumulate(self, item: DatasetItem): - size = item.media.size - if size is None: - log.warning( - "Item %s: can't detect image size, " - "the image will be skipped from pixel statistics", - item.id, - ) - return - count = np.prod(item.media.size) - - image = item.media.data - if len(image.shape) == 2: - image = image[:, :, np.newaxis] - else: - image = image[:, :, :3] - # opencv is much faster than numpy here - mean, std = cv2.meanStdDev(image.astype(np.double) / 255) - - self._stats[(item.id, item.subset)] = (count, mean, std) - - def get_result( - self, - ) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]: - n = len(self._stats) - - if n == 0: - return [0, 0, 0], [0, 0, 0] - - counts = np.empty(n, dtype=np.uint32) - stats = np.empty((n, 2, 3), dtype=np.double) - - for i, v in enumerate(self._stats.values()): - counts[i] = v[0] - stats[i][0] = v[1].reshape(-1) - stats[i][1] = v[2].reshape(-1) - - mean = lambda i, s: s[i][0] - var = lambda i, s: s[i][1] - - # make variance unbiased - np.multiply( - np.square(stats[:, 1]), - (counts / (counts - 1))[:, np.newaxis], - out=stats[:, 1], - ) - - # Use an online algorithm to: - # - handle different image sizes - # - avoid cancellation problem - _, mean, var = self._compute_stats(stats, counts, mean, var) - return mean * 255, np.sqrt(var) * 255 - - # Implements online parallel computation of sample variance - # https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Parallel_algorithm - @staticmethod - def _pairwise_stats(count_a, mean_a, var_a, count_b, mean_b, var_b): - """ - Computes vector mean and variance. - - Needed do avoid catastrophic cancellation in floating point computations - - Returns: - A tuple (total count, mean, variance) - """ - - # allow long arithmetics - count_a = int(count_a) - count_b = int(count_b) - - delta = mean_b - mean_a - m_a = var_a * (count_a - 1) - m_b = var_b * (count_b - 1) - M2 = m_a + m_b + delta**2 * (count_a * count_b / (count_a + count_b)) - - return ( - count_a + count_b, - mean_a * 0.5 + mean_b * 0.5, - M2 / (count_a + count_b - 1), - ) - - @staticmethod - def _compute_stats(stats, counts, mean_accessor, variance_accessor): - """ - Recursively computes total count, mean and variance, - does O(log(N)) calls. - - Args: - stats: (float array of shape N, 2 * d, d = dimensions of values) - count: (integer array of shape N) - mean_accessor: (function(idx, stats)) to retrieve element mean - variance_accessor: (function(idx, stats)) to retrieve element variance - - Returns: - A tuple (total count, mean, variance) - """ - - m = mean_accessor - v = variance_accessor - n = len(stats) - if n == 1: - return counts[0], m(0, stats), v(0, stats) - if n == 2: - return __class__._pairwise_stats( - counts[0], m(0, stats), v(0, stats), counts[1], m(1, stats), v(1, stats) - ) - h = n // 2 - return __class__._pairwise_stats( - *__class__._compute_stats(stats[:h], counts[:h], m, v), - *__class__._compute_stats(stats[h:], counts[h:], m, v), - ) - - -def compute_image_statistics(dataset: IDataset): - stats = { - "dataset": { - "images count": 0, - "unique images count": 0, - "repeated images count": 0, - "repeated images": [], # [[id1, id2], [id3, id4, id5], ...] - }, - "subsets": {}, - } - - stats_counter = _MeanStdCounter() - unique_counter = _ItemMatcher() - - for item in dataset: - stats_counter.accumulate(item) - unique_counter.process_item(item) - - def _extractor_stats(subset_name, extractor): - sub_counter = _MeanStdCounter() - sub_counter._stats = { - k: v - for k, v in stats_counter._stats.items() - if subset_name and k[1] == subset_name or not subset_name - } - - available = len(sub_counter._stats) != 0 - - stats = { - "images count": len(extractor), - } - - if available: - mean, std = sub_counter.get_result() - - stats.update( - { - "image mean": [float(v) for v in mean[::-1]], - "image std": [float(v) for v in std[::-1]], - } - ) - else: - stats.update( - { - "image mean": "n/a", - "image std": "n/a", - } - ) - return stats - - for subset_name in dataset.subsets(): - stats["subsets"][subset_name] = _extractor_stats( - subset_name, dataset.get_subset(subset_name) - ) - - unique_items = unique_counter.get_result() - repeated_items = [sorted(g) for g in unique_items.values() if 1 < len(g)] - - stats["dataset"].update( - { - "images count": len(dataset), - "unique images count": len(unique_items), - "repeated images count": len(repeated_items), - "repeated images": repeated_items, # [[id1, id2], [id3, id4, id5], ...] - } - ) - - return stats - - -def compute_ann_statistics(dataset: IDataset): - labels = dataset.categories().get(AnnotationType.label, LabelCategories()) - - def get_label(ann): - return labels.items[ann.label].name if ann.label is not None else None - - stats = { - "images count": 0, - "annotations count": 0, - "unannotated images count": 0, - "unannotated images": [], - "annotations by type": { - t.name: { - "count": 0, - } - for t in AnnotationType - }, - "annotations": {}, - } - by_type = stats["annotations by type"] - - attr_template = { - "count": 0, - "values count": 0, - "values present": set(), - "distribution": {}, # value -> (count, total%) - } - label_stat = { - "count": 0, - "distribution": {l.name: [0, 0] for l in labels.items}, # label -> (count, total%) - "attributes": {}, - } - stats["annotations"]["labels"] = label_stat - segm_stat = { - "avg. area": 0, - "area distribution": [], # a histogram with 10 bins - # (min, min+10%), ..., (min+90%, max) -> (count, total%) - "pixel distribution": {l.name: [0, 0] for l in labels.items}, # label -> (count, total%) - } - stats["annotations"]["segments"] = segm_stat - segm_areas = [] - pixel_dist = segm_stat["pixel distribution"] - total_pixels = 0 - - for item in dataset: - if len(item.annotations) == 0: - stats["unannotated images"].append(item.id) - continue - - for ann in item.annotations: - by_type[ann.type.name]["count"] += 1 - - if not hasattr(ann, "label") or ann.label is None: - continue - - if ann.type in { - AnnotationType.mask, - AnnotationType.polygon, - AnnotationType.bbox, - }: - area = ann.get_area() - segm_areas.append(area) - pixel_dist[get_label(ann)][0] += int(area) - - label_stat["count"] += 1 - label_stat["distribution"][get_label(ann)][0] += 1 - - for name, value in ann.attributes.items(): - if name.lower() in { - "occluded", - "visibility", - "score", - "id", - "track_id", - }: - continue - attrs_stat = label_stat["attributes"].setdefault(name, deepcopy(attr_template)) - attrs_stat["count"] += 1 - attrs_stat["values present"].add(str(value)) - attrs_stat["distribution"].setdefault(str(value), [0, 0])[0] += 1 - - stats["images count"] = len(dataset) - - stats["annotations count"] = sum(t["count"] for t in stats["annotations by type"].values()) - stats["unannotated images count"] = len(stats["unannotated images"]) - - for label_info in label_stat["distribution"].values(): - label_info[1] = label_info[0] / (label_stat["count"] or 1) - - for label_attr in label_stat["attributes"].values(): - label_attr["values count"] = len(label_attr["values present"]) - label_attr["values present"] = sorted(label_attr["values present"]) - for attr_info in label_attr["distribution"].values(): - attr_info[1] = attr_info[0] / (label_attr["count"] or 1) - - # numpy.sum might be faster, but could overflow with large datasets. - # Python's int can transparently mutate to be of indefinite precision (long) - total_pixels = sum(int(a) for a in segm_areas) - - segm_stat["avg. area"] = total_pixels / (len(segm_areas) or 1.0) - - for label_info in segm_stat["pixel distribution"].values(): - label_info[1] = label_info[0] / (total_pixels or 1) - - if len(segm_areas) != 0: - hist, bins = np.histogram(segm_areas) - segm_stat["area distribution"] = [ - { - "min": float(bin_min), - "max": float(bin_max), - "count": int(c), - "percent": int(c) / len(segm_areas), - } - for c, (bin_min, bin_max) in zip(hist, zip(bins[:-1], bins[1:])) - ] - - return stats - - -@attrs -class DistanceComparator: - iou_threshold = attrib(converter=float, default=0.5) - - def match_annotations(self, item_a, item_b): - return {t: self._match_ann_type(t, item_a, item_b) for t in AnnotationType} - - def _match_ann_type(self, t, *args): - # pylint: disable=no-value-for-parameter - if t == AnnotationType.label: - return self.match_labels(*args) - elif t == AnnotationType.bbox: - return self.match_boxes(*args) - elif t == AnnotationType.polygon: - return self.match_polygons(*args) - elif t == AnnotationType.mask: - return self.match_masks(*args) - elif t == AnnotationType.points: - return self.match_points(*args) - elif t == AnnotationType.polyline: - return self.match_lines(*args) - # pylint: enable=no-value-for-parameter + a_unmatched = [] + b_unmatched = [] + + for a_idx, b_idx in zip(a_matches, b_matches): + dist = distances[a_idx, b_idx] + if dist > 1 - dist_thresh or dist == 1: + if a_idx < len(a_segms): + a_unmatched.append(a_segms[a_idx]) + if b_idx < len(b_segms): + b_unmatched.append(b_segms[b_idx]) else: - raise NotImplementedError("Unexpected annotation type %s" % t) - - @staticmethod - def _get_ann_type(t, item): - return [a for a in item.annotations if a.type == t] - - def match_labels(self, item_a, item_b): - a_labels = set(a.label for a in self._get_ann_type(AnnotationType.label, item_a)) - b_labels = set(a.label for a in self._get_ann_type(AnnotationType.label, item_b)) - - matches = a_labels & b_labels - a_unmatched = a_labels - b_labels - b_unmatched = b_labels - a_labels - return matches, a_unmatched, b_unmatched - - def _match_segments(self, t, item_a, item_b): - a_boxes = self._get_ann_type(t, item_a) - b_boxes = self._get_ann_type(t, item_b) - return match_segments(a_boxes, b_boxes, dist_thresh=self.iou_threshold) - - def match_polygons(self, item_a, item_b): - return self._match_segments(AnnotationType.polygon, item_a, item_b) - - def match_masks(self, item_a, item_b): - return self._match_segments(AnnotationType.mask, item_a, item_b) - - def match_boxes(self, item_a, item_b): - return self._match_segments(AnnotationType.bbox, item_a, item_b) - - def match_points(self, item_a, item_b): - a_points = self._get_ann_type(AnnotationType.points, item_a) - b_points = self._get_ann_type(AnnotationType.points, item_b) - - instance_map = {} - for s in [item_a.annotations, item_b.annotations]: - s_instances = find_instances(s) - for inst in s_instances: - inst_bbox = max_bbox(inst) - for ann in inst: - instance_map[id(ann)] = [inst, inst_bbox] - matcher = PointsMatcher(instance_map=instance_map) - - return match_segments( - a_points, - b_points, - dist_thresh=self.iou_threshold, - distance=matcher.distance, - ) - - def match_lines(self, item_a, item_b): - a_lines = self._get_ann_type(AnnotationType.polyline, item_a) - b_lines = self._get_ann_type(AnnotationType.polyline, item_b) - - matcher = LineMatcher() - - return match_segments( - a_lines, b_lines, dist_thresh=self.iou_threshold, distance=matcher.distance - ) - - -def match_items_by_id(a: IDataset, b: IDataset): - a_items = set((item.id, item.subset) for item in a) - b_items = set((item.id, item.subset) for item in b) - - matches = a_items & b_items - matches = [([m], [m]) for m in matches] - a_unmatched = a_items - b_items - b_unmatched = b_items - a_items - return matches, a_unmatched, b_unmatched - - -def match_items_by_image_hash(a: IDataset, b: IDataset): - a_hash = find_unique_images(a) - b_hash = find_unique_images(b) - - a_items = set(a_hash) - b_items = set(b_hash) - - matches = a_items & b_items - a_unmatched = a_items - b_items - b_unmatched = b_items - a_items - - matches = [(a_hash[h], b_hash[h]) for h in matches] - a_unmatched = set(i for h in a_unmatched for i in a_hash[h]) - b_unmatched = set(i for h in b_unmatched for i in b_hash[h]) - - return matches, a_unmatched, b_unmatched - - -class _ItemMatcher: - @staticmethod - def _default_item_hash(item: DatasetItem): - if not item.media or not item.media.has_data: - if item.media and item.media.path: - return hash(item.media.path) - - log.warning( - "Item (%s, %s) has no image " "info, counted as unique", - item.id, - item.subset, - ) - return None - - # Disable B303:md5, because the hash is not used in a security context - return hashlib.md5(item.media.data.tobytes()).hexdigest() # nosec - - def __init__(self, item_hash: Optional[Callable] = None): - self._hash = item_hash or self._default_item_hash - - # hash -> [(id, subset), ...] - self._unique: Dict[str, Set[Tuple[str, str]]] = {} - - def process_item(self, item: DatasetItem): - h = self._hash(item) - if h is None: - h = str(id(item)) # anything unique - - self._unique.setdefault(h, set()).add((item.id, item.subset)) - - def get_result(self): - return self._unique - - -def find_unique_images(dataset: IDataset, item_hash: Optional[Callable] = None): - matcher = _ItemMatcher(item_hash=item_hash) - for item in dataset: - matcher.process_item(item) - return matcher.get_result() - - -def match_classes(a: CategoriesInfo, b: CategoriesInfo): - a_label_cat = a.get(AnnotationType.label, LabelCategories()) - b_label_cat = b.get(AnnotationType.label, LabelCategories()) - - a_labels = set(c.name for c in a_label_cat) - b_labels = set(c.name for c in b_label_cat) - - matches = a_labels & b_labels - a_unmatched = a_labels - b_labels - b_unmatched = b_labels - a_labels - return matches, a_unmatched, b_unmatched - - -@attrs -class ExactComparator: - match_images: bool = attrib(kw_only=True, default=False) - ignored_fields = attrib(kw_only=True, factory=set, validator=default_if_none(set)) - ignored_attrs = attrib(kw_only=True, factory=set, validator=default_if_none(set)) - ignored_item_attrs = attrib(kw_only=True, factory=set, validator=default_if_none(set)) - - _test: TestCase = attrib(init=False) - errors: list = attrib(init=False) - - def __attrs_post_init__(self): - self._test = TestCase() - self._test.maxDiff = None - - def _match_items(self, a, b): - if self.match_images: - return match_items_by_image_hash(a, b) - else: - return match_items_by_id(a, b) - - def _compare_categories(self, a, b): - test = self._test - errors = self.errors - - try: - test.assertEqual(sorted(a, key=lambda t: t.value), sorted(b, key=lambda t: t.value)) - except AssertionError as e: - errors.append({"type": "categories", "message": str(e)}) - - if AnnotationType.label in a: - try: - test.assertEqual( - a[AnnotationType.label].items, - b[AnnotationType.label].items, - ) - except AssertionError as e: - errors.append({"type": "labels", "message": str(e)}) - if AnnotationType.mask in a: - try: - test.assertEqual( - a[AnnotationType.mask].colormap, - b[AnnotationType.mask].colormap, - ) - except AssertionError as e: - errors.append({"type": "colormap", "message": str(e)}) - if AnnotationType.points in a: - try: - test.assertEqual( - a[AnnotationType.points].items, - b[AnnotationType.points].items, - ) - except AssertionError as e: - errors.append({"type": "points", "message": str(e)}) - - def _compare_annotations(self, a, b): - ignored_fields = self.ignored_fields - ignored_attrs = self.ignored_attrs - - a_fields = {k: None for k in a.as_dict() if k in ignored_fields} - b_fields = {k: None for k in b.as_dict() if k in ignored_fields} - if "attributes" not in ignored_fields: - a_fields["attributes"] = filter_dict(a.attributes, ignored_attrs) - b_fields["attributes"] = filter_dict(b.attributes, ignored_attrs) - - result = a.wrap(**a_fields) == b.wrap(**b_fields) - - return result - - def _compare_items(self, item_a, item_b): - test = self._test - - a_id = (item_a.id, item_a.subset) - b_id = (item_b.id, item_b.subset) - - matched = [] - unmatched = [] - errors = [] - - try: - test.assertEqual( - filter_dict(item_a.attributes, self.ignored_item_attrs), - filter_dict(item_b.attributes, self.ignored_item_attrs), - ) - except AssertionError as e: - errors.append({"type": "item_attr", "a_item": a_id, "b_item": b_id, "message": str(e)}) - - b_annotations = item_b.annotations[:] - for ann_a in item_a.annotations: - ann_b_candidates = [x for x in item_b.annotations if x.type == ann_a.type] - - ann_b = find( - enumerate(self._compare_annotations(ann_a, x) for x in ann_b_candidates), - lambda x: x[1], - ) - if ann_b is None: - unmatched.append( - { - "item": a_id, - "source": "a", - "ann": str(ann_a), - } - ) - continue + a_ann = a_segms[a_idx] + b_ann = b_segms[b_idx] + if label_matcher(a_ann, b_ann): + matches.append((a_ann, b_ann)) else: - ann_b = ann_b_candidates[ann_b[0]] - - b_annotations.remove(ann_b) # avoid repeats - matched.append({"a_item": a_id, "b_item": b_id, "a": str(ann_a), "b": str(ann_b)}) - - for ann_b in b_annotations: - unmatched.append({"item": b_id, "source": "b", "ann": str(ann_b)}) + mispred.append((a_ann, b_ann)) - return matched, unmatched, errors + if not len(a_matches) and not len(b_matches): + a_unmatched = list(a_segms) + b_unmatched = list(b_segms) - def compare_datasets(self, a, b): - self.errors = [] - errors = self.errors - - self._compare_categories(a.categories(), b.categories()) - - matched = [] - unmatched = [] - - matches, a_unmatched, b_unmatched = self._match_items(a, b) - - if a.categories().get(AnnotationType.label) != b.categories().get(AnnotationType.label): - return matched, unmatched, a_unmatched, b_unmatched, errors - - _dist = lambda s: len(s[1]) + len(s[2]) - for a_ids, b_ids in matches: - # build distance matrix - match_status = {} # (a_id, b_id): [matched, unmatched, errors] - a_matches = {a_id: None for a_id in a_ids} - b_matches = {b_id: None for b_id in b_ids} - - for a_id in a_ids: - item_a = a.get(*a_id) - candidates = {} - - for b_id in b_ids: - item_b = b.get(*b_id) - - i_m, i_um, i_err = self._compare_items(item_a, item_b) - candidates[b_id] = [i_m, i_um, i_err] - - if len(i_um) == 0: - a_matches[a_id] = b_id - b_matches[b_id] = a_id - matched.extend(i_m) - errors.extend(i_err) - break - - match_status[a_id] = candidates - - # assign - for a_id in a_ids: - if len(b_ids) == 0: - break - - # find the closest, ignore already assigned - matched_b = a_matches[a_id] - if matched_b is not None: - continue - min_dist = -1 - for b_id in b_ids: - if b_matches[b_id] is not None: - continue - d = _dist(match_status[a_id][b_id]) - if d < min_dist and 0 <= min_dist: - continue - min_dist = d - matched_b = b_id - - if matched_b is None: - continue - a_matches[a_id] = matched_b - b_matches[matched_b] = a_id - - m = match_status[a_id][matched_b] - matched.extend(m[0]) - unmatched.extend(m[1]) - errors.extend(m[2]) - - a_unmatched |= set(a_id for a_id, m in a_matches.items() if not m) - b_unmatched |= set(b_id for b_id, m in b_matches.items() if not m) - - return matched, unmatched, a_unmatched, b_unmatched, errors + return matches, mispred, a_unmatched, b_unmatched From 6c24859c0c04821f4db52ba159de0d01660bdba0 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 28 Jul 2024 15:12:03 +0530 Subject: [PATCH 121/301] formatted python code --- cvat/apps/consensus/merge_consensus_jobs.py | 5 ++- .../apps/consensus/migrations/0001_initial.py | 2 +- cvat/apps/consensus/new_intersect_merge.py | 40 +++++++++++++------ cvat/apps/consensus/serializers.py | 9 +---- cvat/apps/quality_control/quality_reports.py | 4 +- 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index 2c08b950afb..15962ac3da7 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -7,8 +7,6 @@ import datumaro as dm import django_rq -# from datumaro.components.operations import IntersectMerge -from cvat.apps.consensus.new_intersect_merge import IntersectMerge from django.conf import settings from django.db import transaction from django.utils import timezone @@ -23,6 +21,9 @@ save_report, ) from cvat.apps.consensus.models import ConsensusSettings + +# from datumaro.components.operations import IntersectMerge +from cvat.apps.consensus.new_intersect_merge import IntersectMerge from cvat.apps.dataset_manager.bindings import import_dm_annotations from cvat.apps.dataset_manager.task import PatchAction, patch_job_data from cvat.apps.engine.models import Job, JobType, StageChoice, StateChoice, Task diff --git a/cvat/apps/consensus/migrations/0001_initial.py b/cvat/apps/consensus/migrations/0001_initial.py index 6a981f7be47..54f6474cc98 100644 --- a/cvat/apps/consensus/migrations/0001_initial.py +++ b/cvat/apps/consensus/migrations/0001_initial.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.13 on 2024-07-28 09:18 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/consensus/new_intersect_merge.py b/cvat/apps/consensus/new_intersect_merge.py index 620ad87c081..69269915678 100644 --- a/cvat/apps/consensus/new_intersect_merge.py +++ b/cvat/apps/consensus/new_intersect_merge.py @@ -5,11 +5,24 @@ import itertools import logging as log +import math from collections import OrderedDict from copy import deepcopy from functools import cached_property, partial -import math -from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Sequence, Tuple, Type, Union, cast +from typing import ( + Any, + Callable, + Dict, + Hashable, + Iterable, + List, + Optional, + Sequence, + Tuple, + Type, + Union, + cast, +) import attr import datumaro as dm @@ -84,11 +97,12 @@ def OKS(a, b, sigma=0.1, bbox=None, scale=None, visibility_a=None, visibility_b= dists = np.linalg.norm(p1 - p2, axis=1) return np.sum( - visibility_a * visibility_b * np.exp((visibility_a == visibility_b)*(-(dists**2) / (2 * scale * (2 * sigma) ** 2))) + visibility_a + * visibility_b + * np.exp((visibility_a == visibility_b) * (-(dists**2) / (2 * scale * (2 * sigma) ** 2))) ) / np.sum(visibility_a | visibility_b, dtype=float) - def match_annotations_equal(a, b): matches = [] a_unmatched = a[:] @@ -792,8 +806,8 @@ def _for_type(t, **kwargs): elif t is AnnotationType.skeleton: return _make(SkeletonMerger, **kwargs) # else: - # pass - # raise NotImplementedError("Type %s is not supported" % t) + # pass + # raise NotImplementedError("Type %s is not supported" % t) instance_map = {} for s in sources: @@ -814,7 +828,10 @@ def _for_type(t, **kwargs): for ann in inst: instance_map[id(ann)] = [inst, inst_bbox] - self._mergers = {t: _for_type(t, instance_map=instance_map, categories=self._categories) for t in AnnotationType} + self._mergers = { + t: _for_type(t, instance_map=instance_map, categories=self._categories) + for t in AnnotationType + } def _match_ann_type(self, t, sources): return self._mergers[t].match_annotations(sources) @@ -1059,9 +1076,7 @@ def memoizing_distance(a, b): @staticmethod def _get_ann_type(t, item: dm.Annotation) -> Sequence[dm.Annotation]: - return [ - a for a in item if a.type == t and not a.attributes.get("outside", False) - ] + return [a for a in item if a.type == t and not a.attributes.get("outside", False)] def _match_segments( self, @@ -1216,6 +1231,7 @@ def _bbox_iou(a: dm.Bbox, b: dm.Bbox, *, img_w: int, img_h: int) -> float: return dm.ops.bbox_iou(a, b) else: return segment_iou(_to_polygon(a), _to_polygon(b), img_h=img_h, img_w=img_w) + dataitem_id = self._context._ann_map[id(a)][1] img_h, img_w = self._context._item_map[dataitem_id][0].image.size return _bbox_iou(a, b, img_h=img_h, img_w=img_w) @@ -1880,6 +1896,7 @@ class PointsMerger(_ShapeMerger, PointsMatcher): class LineMerger(_ShapeMerger, LineMatcher): pass + class SkeletonMerger(_ShapeMerger, SkeletonMatcher): def _merge_cluster_shape_nearest(self, cluster): dist = {} @@ -1903,13 +1920,12 @@ def merge_cluster_shape(self, cluster): shape = self._merge_cluster_shape_nearest(cluster) shape_score = sum(max(0, self.distance(shape, s)) for s in cluster) / len(cluster) return shape, shape_score + # def __init__(self, categories=None): # _ShapeMerger.__init__(self) # SkeletonMatcher.__init__(self, categories) - - def match_segments( a_segms, b_segms, diff --git a/cvat/apps/consensus/serializers.py b/cvat/apps/consensus/serializers.py index 009a4c863d8..c5f32010ed6 100644 --- a/cvat/apps/consensus/serializers.py +++ b/cvat/apps/consensus/serializers.py @@ -64,14 +64,7 @@ class ConsensusReportCreateSerializer(serializers.Serializer): class ConsensusSettingsSerializer(serializers.ModelSerializer): class Meta: model = models.ConsensusSettings - fields = ( - "id", - "task_id", - "iou_threshold", - "agreement_score_threshold", - "quorum", - "sigma" - ) + fields = ("id", "task_id", "iou_threshold", "agreement_score_threshold", "quorum", "sigma") read_only_fields = ( "id", "task_id", diff --git a/cvat/apps/quality_control/quality_reports.py b/cvat/apps/quality_control/quality_reports.py index d30cc92ed6e..27d0d2ff178 100644 --- a/cvat/apps/quality_control/quality_reports.py +++ b/cvat/apps/quality_control/quality_reports.py @@ -728,7 +728,9 @@ def _OKS(a, b, sigma=0.1, bbox=None, scale=None, visibility_a=None, visibility_b dists = np.linalg.norm(p1 - p2, axis=1) return np.sum( - visibility_a * visibility_b * np.exp((visibility_a == visibility_b)*(-(dists**2) / (2 * scale * (2 * sigma) ** 2))) + visibility_a + * visibility_b + * np.exp((visibility_a == visibility_b) * (-(dists**2) / (2 * scale * (2 * sigma) ** 2))) ) / np.sum(visibility_a | visibility_b, dtype=float) From 0412835dd3e1aaa799d445d2d1acb6b691996af8 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 31 Jul 2024 01:22:25 +0530 Subject: [PATCH 122/301] added consensus jobs and their annotations into backup --- cvat/apps/engine/backup.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index 1d84de9b71d..3eba5b054a5 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -291,7 +291,7 @@ def _get_db_jobs(self): if self._db_task: db_segments = list(self._db_task.segment_set.all().prefetch_related('job_set')) db_segments.sort(key=lambda i: i.job_set.first().id) - db_jobs = (s.job_set.first() for s in db_segments) + db_jobs = (job for s in db_segments for job in s.job_set.all()) return db_jobs return () @@ -396,27 +396,31 @@ def serialize_task(): return task def serialize_segment(db_segment): - db_job = db_segment.job_set.first() - job_serializer = SimpleJobSerializer(db_job) - for field in ('url', 'assignee'): - job_serializer.fields.pop(field) - job_data = self._prepare_job_meta(job_serializer.data) + segments = [] + db_jobs = db_segment.job_set.all() + for db_job in db_jobs: + job_serializer = SimpleJobSerializer(db_job) + for field in ('url', 'assignee'): + job_serializer.fields.pop(field) + job_data = self._prepare_job_meta(job_serializer.data) - segment_serializer = SegmentSerializer(db_segment) - segment_serializer.fields.pop('jobs') - segment = segment_serializer.data - segment_type = segment.pop("type") - segment.update(job_data) + segment_serializer = SegmentSerializer(db_segment) + segment_serializer.fields.pop('jobs') + segment = segment_serializer.data + 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: + segment.update(serialize_custom_file_mapping(db_segment)) - return segment + segments.append(segment) + + return segments def serialize_jobs(): db_segments = list(self._db_task.segment_set.all()) db_segments.sort(key=lambda i: i.job_set.first().id) - return (serialize_segment(s) for s in db_segments) + return (serialized_job for s in db_segments for serialized_job in serialize_segment(s)) def serialize_custom_file_mapping(db_segment: models.Segment): if self._db_task.mode == 'annotation': @@ -724,7 +728,7 @@ def _import_gt_jobs(self, jobs): }) job_serializer.is_valid(raise_exception=True) job_serializer.save() - elif job_type == models.JobType.ANNOTATION: + elif job_type == models.JobType.ANNOTATION or job_type == models.JobType.CONSENSUS: continue else: assert False From f5a488073673699142e6b929e02c6bccd5737283 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 31 Jul 2024 01:24:01 +0530 Subject: [PATCH 123/301] added `restore_db_per_function` to TestTaskBackups --- tests/python/rest_api/test_tasks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index ce69e8fec8e..ec4c9da1ea5 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -2245,6 +2245,7 @@ def test_work_with_task_containing_non_stable_cloud_storage_files( assert image_name in ex.body +@pytest.mark.usefixtures("restore_db_per_function") class TestTaskBackups: def _make_client(self) -> Client: return Client(BASE_URL, config=Config(status_check_period=0.01)) From 89b86dffb111d66e2f21555c970f75ae71e5acd2 Mon Sep 17 00:00:00 2001 From: vidit Date: Sun, 4 Aug 2024 01:51:31 +0530 Subject: [PATCH 124/301] fixed `JobSummarySerializer` to include all types of Jobs in `TaskSummary` --- cvat/apps/engine/serializers.py | 52 +++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 01e846e6852..1c7db81b527 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -147,12 +147,54 @@ def get_fields(self): def get_attribute(self, instance): return instance -class JobsSummarySerializer(_CollectionSummarySerializer): - completed = serializers.IntegerField(source='completed_jobs_count', allow_null=True) - validation = serializers.IntegerField(source='validation_jobs_count', allow_null=True) +class JobsSummarySerializer(serializers.Serializer): + url = serializers.URLField(read_only=True) + count = serializers.IntegerField(read_only=True) + completed = serializers.IntegerField(read_only=True) + validation = serializers.IntegerField(read_only=True) def __init__(self, *, model=models.Job, url_filter_key, **kwargs): - super().__init__(model=model, url_filter_key=url_filter_key, **kwargs) + super().__init__(**kwargs) + self._model = model + self._url_filter_key = url_filter_key + + def get_url(self, request, instance): + return reverse( + "job-list", request=request, query_params={"task_id": instance.id} + ) + + def get_counts(self, instance): + jobs = [ + j + for j in list( + self._model.objects.filter(segment__task=instance) + ) + ] + return { + "count": len(jobs), + "completed": len( + [ + j + for j in jobs + if j.status == models.StatusChoice.COMPLETED.value + and j.stage == models.StageChoice.ACCEPTANCE.value + ] + ), + "validation": len( + [j for j in jobs if j.stage == models.StageChoice.VALIDATION.value] + ), + } + + def to_representation(self, instance): + request = self.context.get("request") + if not request: + return None + + + representation = self.get_counts(instance) + representation["url"] = self.get_url(request, instance) + + return representation class TasksSummarySerializer(_CollectionSummarySerializer): @@ -1116,7 +1158,7 @@ class TaskReadSerializer(serializers.ModelSerializer): dimension = serializers.CharField(allow_blank=True, required=False) target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) - jobs = JobsSummarySerializer(url_filter_key='task_id', source='segment_set') + jobs = JobsSummarySerializer(url_filter_key='task_id', source="*") labels = LabelsSummarySerializer(source='*') consensus_jobs_per_regular_job = serializers.ReadOnlyField(required=False, allow_null=True) From 2c2ca6af8358618b3d23781cd5d3f1e255288820 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 6 Aug 2024 02:07:19 +0530 Subject: [PATCH 125/301] fixed a typo in project as peoject --- cvat-core/src/server-response-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index 6cff4193904..77d465e701f 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -292,7 +292,7 @@ export interface SerializedQualityConflictData { export interface APIQualityReportsFilter extends APICommonFilterParams { parent_id?: number; - peoject_id?: number; + project_id?: number; task_id?: number; job_id?: number; target?: string; From 9315d677fffb4d5477909bf499c06c7ce81b45c6 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 6 Aug 2024 02:08:30 +0530 Subject: [PATCH 126/301] added consensus score and assignee to consensus report --- cvat/apps/consensus/consensus_reports.py | 64 +++++++++++++++++++-- cvat/apps/consensus/merge_consensus_jobs.py | 1 + cvat/apps/consensus/models.py | 18 +++--- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/cvat/apps/consensus/consensus_reports.py b/cvat/apps/consensus/consensus_reports.py index 47e184314dd..2948ffcb184 100644 --- a/cvat/apps/consensus/consensus_reports.py +++ b/cvat/apps/consensus/consensus_reports.py @@ -10,6 +10,7 @@ from copy import deepcopy from datetime import timedelta from functools import cached_property, partial +from types import NoneType from typing import Any, Callable, Dict, Hashable, List, Optional, Sequence, Tuple, Union, cast from uuid import uuid4 @@ -127,6 +128,7 @@ def from_dict(cls, d: dict): @define(kw_only=True, init=False) class ComparisonReportFrameSummary(_Serializable): conflicts: List[AnnotationConflict] + consensus_score: float @cached_property def conflict_count(self) -> int: @@ -173,6 +175,7 @@ def from_dict(cls, d: dict): else {} ), conflicts=[AnnotationConflict.from_dict(v) for v in d["conflicts"]], + consensus_score=d["consensus_score"], ) @@ -218,6 +221,25 @@ class ComparisonReport(_Serializable): def conflicts(self) -> List[AnnotationConflict]: return list(itertools.chain.from_iterable(r.conflicts for r in self.frame_results.values())) + @property + def consensus_score(self) -> float: + mean_consensus_score = 0 + frame_count = 0 + for frame_result in self.frame_results.values(): + if not isinstance(frame_result.consensus_score, NoneType): + mean_consensus_score += frame_result.consensus_score + frame_count += 1 + + return mean_consensus_score / (frame_count or 1) + + def _fields_dict(self, *, include_properties: Optional[List[str]] = None) -> dict: + return super()._fields_dict( + include_properties=include_properties + or [ + "consensus_score", + ] + ) + @classmethod def from_dict(cls, d: Dict[str, Any]) -> ComparisonReport: return cls( @@ -245,12 +267,15 @@ def generate_job_consensus_report( consensus_settings: ConsensusSettings, errors, consensus_job_data_providers: List[JobDataProvider], + merged_dataset: dm.Dataset, ) -> ComparisonReport: frame_results: Dict[int, ComparisonReportFrameSummary] = {} frames = set() conflicts_count = len(errors) - conflicts = [] + frame_wise_conflicts: Dict[int, List[AnnotationConflict]] = {} + frame_wise_consensus_score: Dict[int, List[float]] = {} + conflicts: List[AnnotationConflict] = [] for error in errors: error_type = str(type(error)).split(".")[-1].split("'")[0] @@ -274,7 +299,7 @@ def generate_job_consensus_report( dm_item = consensus_job_data_providers[0].dm_dataset.get(error.item_id[0]) frame_id: int = consensus_job_data_providers[0].dm_item_id_to_frame_id(dm_item) frames.add(frame_id) - frame_results.setdefault(frame_id, []).append( + frame_wise_conflicts.setdefault(frame_id, []).append( AnnotationConflict( frame_id=frame_id, type=error_type, @@ -282,9 +307,19 @@ def generate_job_consensus_report( ) ) - for frame_id in frame_results: - conflicts += frame_results[frame_id] - frame_results[frame_id] = ComparisonReportFrameSummary(conflicts=frame_results[frame_id]) + for dataset_item in merged_dataset: + frame_id = consensus_job_data_providers[0].dm_item_id_to_frame_id(dataset_item) + frames.add(frame_id) + frame_wise_consensus_score.setdefault(frame_id, []).append( + np.mean([ann.attributes.get("score", 0) for ann in dataset_item.annotations]) + ) + + for frame_id in frames: + conflicts += frame_wise_conflicts.get(frame_id, []) + frame_results[frame_id] = ComparisonReportFrameSummary( + conflicts=frame_wise_conflicts.get(frame_id, []), + consensus_score=np.mean(frame_wise_consensus_score.get(frame_id, [0])), + ) return ComparisonReport( parameters=ComparisonParameters.from_dict(consensus_settings.to_dict()), @@ -316,6 +351,10 @@ def generate_task_consensus_report(job_reports: List[ComparisonReport]) -> Compa task_frame_result = deepcopy(job_frame_result) else: task_frame_result.conflicts += job_frame_result.conflicts + task_frame_result.consensus_score = ( + task_frame_result.consensus_score * task_frame_results_counts[frame_id] + + job_frame_result.consensus_score + ) / (task_frame_results_counts[frame_id] + 1) task_frame_results_counts[frame_id] = 1 + frame_results_count task_frame_results[frame_id] = task_frame_result @@ -362,19 +401,26 @@ def save_report( # # with another one # return + mean_consensus_score = 0 + job_reports = {} for job_id in jobs: job_comparison_report = job_report_data[job_id] job = Job.objects.filter(id=job_id).first() + job_consensus_score = job_comparison_report.consensus_score job_report = dict( job=job, target_last_updated=job.updated_date, data=job_comparison_report.to_json(), conflicts=[c.to_dict() for c in job_comparison_report.conflicts], + consensus_score=job_consensus_score, + assignee=job.assignee, ) - + mean_consensus_score += job_consensus_score job_reports[job.id] = job_report + mean_consensus_score /= len(jobs) + job_reports = list(job_reports.values()) task_report = dict( @@ -382,12 +428,16 @@ def save_report( target_last_updated=task.updated_date, data=task_report_data.to_json(), conflicts=[], # the task doesn't have own conflicts + consensus_score=mean_consensus_score, + assignee=task.assignee, ) db_task_report = ConsensusReport( task=task_report["task"], target_last_updated=task_report["target_last_updated"], data=task_report["data"], + consensus_score=task_report["consensus_score"], + assignee=task_report["assignee"], ) db_task_report.save() @@ -397,6 +447,8 @@ def save_report( job=job_report["job"], target_last_updated=job_report["target_last_updated"], data=job_report["data"], + consensus_score=job_report["consensus_score"], + assignee=job_report["assignee"], ) db_job_reports.append(db_job_report) diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index 15962ac3da7..114af4b4b3c 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -116,6 +116,7 @@ def _merge_consensus_jobs(task_id: int) -> None: consensus_settings=consensus_settings, errors=merger.errors, consensus_job_data_providers=consensus_job_data_providers, + merged_dataset=merged_dataset, ) parent_job = Job.objects.filter(id=parent_job_id, type=JobType.ANNOTATION.value).first() parent_job.state = StateChoice.COMPLETED.value diff --git a/cvat/apps/consensus/models.py b/cvat/apps/consensus/models.py index 02772efb3d0..5d52236b107 100644 --- a/cvat/apps/consensus/models.py +++ b/cvat/apps/consensus/models.py @@ -20,16 +20,16 @@ from django.db import models from django.forms.models import model_to_dict -from cvat.apps.engine.models import Job, ShapeType, Task +from cvat.apps.engine.models import Job, ShapeType, Task, User class ConsensusConflictType(str, Enum): - NoMatchingItemError = "NO_MATCHING_ITEM" - FailedAttrVotingError = "FAILED_ATTRIBUTE_VOTING" - NoMatchingAnnError = "NO_MATCHING_ANNOTATION" - AnnotationsTooCloseError = "ANNOTATION_TOO_CLOSE" - WrongGroupError = "WRONG_GROUP" - FailedLabelVotingError = "FAILED_LABEL_VOTING" + NoMatchingItemError = "no_matching_item" + FailedAttrVotingError = "failed_attribute_voting" + NoMatchingAnnError = "no_matching_annotation" + AnnotationsTooCloseError = "annotation_too_close" + WrongGroupError = "wrong_group" + FailedLabelVotingError = "failed_label_voting" def __str__(self) -> str: return self.value @@ -97,6 +97,10 @@ class ConsensusReport(models.Model): created_date = models.DateTimeField(auto_now_add=True) target_last_updated = models.DateTimeField() + consensus_score = models.FloatField() + assignee = models.ForeignKey( + User, on_delete=models.SET_NULL, related_name="consensus", null=True, blank=True + ) data = models.JSONField() From 7ede7cb656fa1f04d69c872481b4bc932fa98520 Mon Sep 17 00:00:00 2001 From: vidit Date: Tue, 6 Aug 2024 02:11:16 +0530 Subject: [PATCH 127/301] added separate consensus merge and analytics button --- .../src/components/actions-menu/actions-menu.tsx | 16 ++++++++++++++++ cvat-ui/src/components/cvat-app.tsx | 2 ++ cvat-ui/src/components/task-page/top-bar.tsx | 5 +++++ cvat-ui/src/components/tasks-page/task-item.tsx | 5 +++++ .../src/containers/actions-menu/actions-menu.tsx | 13 ++++++++++++- 5 files changed, 40 insertions(+), 1 deletion(-) diff --git a/cvat-ui/src/components/actions-menu/actions-menu.tsx b/cvat-ui/src/components/actions-menu/actions-menu.tsx index 76e20c5fd2c..fe4f10227b3 100644 --- a/cvat-ui/src/components/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/components/actions-menu/actions-menu.tsx @@ -36,6 +36,8 @@ export enum Actions { BACKUP_TASK = 'backup_task', VIEW_ANALYTICS = 'view_analytics', SHOW_TASK_CONSENSUS_CONFIGURATION = 'show_task_consensus_configuration', + VIEW_CONSENSUS_ANALYTICS = 'view_consensus_analytics', + MERGE_CONSENSUS_JOBS = 'merge_consensus_jobs', } function ActionsMenuComponent(props: Props): JSX.Element { @@ -122,6 +124,20 @@ function ActionsMenuComponent(props: Props): JSX.Element { Consensus configuration ), 55]); + menuItems.push([( + + View Consensus Analytics + + ), 55]); + menuItems.push([( + + Merge Consensus Jobs + + ), 55]); } if (projectID === null) { diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index dfadbc2362d..b89f1d4b748 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -83,6 +83,7 @@ import EmailVerificationSentPage from './email-confirmation-pages/email-verifica import IncorrectEmailConfirmationPage from './email-confirmation-pages/incorrect-email-confirmation'; import CreateJobPage from './create-job-page/create-job-page'; import AnalyticsPage from './analytics-page/analytics-page'; +import TaskConsensusAnalyticsPage from './analytics-page/consensus-analytics-page'; import InvitationWatcher from './invitation-watcher/invitation-watcher'; interface CVATAppProps { @@ -514,6 +515,7 @@ class CVATApplication extends React.PureComponent + diff --git a/cvat-ui/src/components/task-page/top-bar.tsx b/cvat-ui/src/components/task-page/top-bar.tsx index cab6e21bac0..cbf5702cf61 100644 --- a/cvat-ui/src/components/task-page/top-bar.tsx +++ b/cvat-ui/src/components/task-page/top-bar.tsx @@ -26,6 +26,10 @@ export default function DetailsComponent(props: DetailsComponentProps): JSX.Elem history.push(`/tasks/${taskInstance.id}/analytics`); }, [history]); + const onViewConsensusAnalytics = (): void => { + history.push(`/tasks/${taskInstance.id}/analytics/consensus`); + }; + return ( @@ -59,6 +63,7 @@ export default function DetailsComponent(props: DetailsComponentProps): JSX.Elem )} > diff --git a/cvat-ui/src/components/tasks-page/task-item.tsx b/cvat-ui/src/components/tasks-page/task-item.tsx index 4719c0a0d6f..c397fec2d0a 100644 --- a/cvat-ui/src/components/tasks-page/task-item.tsx +++ b/cvat-ui/src/components/tasks-page/task-item.tsx @@ -239,6 +239,10 @@ class TaskItemComponent extends React.PureComponent { + history.push(`/tasks/${taskInstance.id}/analytics/consensus`); + }; + return ( @@ -267,6 +271,7 @@ class TaskItemComponent extends React.PureComponent )} > diff --git a/cvat-ui/src/containers/actions-menu/actions-menu.tsx b/cvat-ui/src/containers/actions-menu/actions-menu.tsx index fa47e296236..39fb3e91cc0 100644 --- a/cvat-ui/src/containers/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/containers/actions-menu/actions-menu.tsx @@ -17,11 +17,12 @@ import { } from 'actions/tasks-actions'; import { exportActions } from 'actions/export-actions'; import { importActions } from 'actions/import-actions'; -import { consensusActions } from 'actions/consensus-actions'; +import { consensusActions, mergeTaskConsensusJobsAsync } from 'actions/consensus-actions'; interface OwnProps { taskInstance: any; onViewAnalytics: () => void; + onViewConsensusAnalytics: () => void; } interface StateToProps { @@ -36,6 +37,7 @@ interface DispatchToProps { deleteTask: (taskInstance: any) => void; openMoveTaskToProjectWindow: (taskInstance: any) => void; showConsensusModal: (taskInstance: any) => void; + mergeConsensusJobs: (taskInstance: any) => void; } function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { @@ -77,6 +79,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { showConsensusModal: (taskInstance: any): void => { dispatch(consensusActions.openConsensusModal(taskInstance)); }, + mergeConsensusJobs: (taskInstance: any): void => { + dispatch(mergeTaskConsensusJobsAsync(taskInstance)); + }, }; } @@ -92,6 +97,8 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): openMoveTaskToProjectWindow, onViewAnalytics, showConsensusModal, + onViewConsensusAnalytics, + mergeConsensusJobs, } = props; const onClickMenu = (params: MenuInfo): void | JSX.Element => { const [action] = params.keyPath; @@ -113,6 +120,10 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): onViewAnalytics(); } else if (action === Actions.SHOW_TASK_CONSENSUS_CONFIGURATION) { showConsensusModal(taskInstance); + } else if (action === Actions.VIEW_CONSENSUS_ANALYTICS) { + onViewConsensusAnalytics(); + } else if (action === Actions.MERGE_CONSENSUS_JOBS) { + mergeConsensusJobs(taskInstance); } }; From cc036a46a4c54353bf83763903d367829bbd5dd5 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 7 Aug 2024 01:12:08 +0530 Subject: [PATCH 128/301] fixed that `count` param wasn't considering `consensus jobs` in Task JobsSummary --- cvat/apps/engine/models.py | 1 + cvat/apps/engine/serializers.py | 56 +++++---------------------------- 2 files changed, 9 insertions(+), 48 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 96994f0ee6f..44a41efadfe 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -380,6 +380,7 @@ def with_job_summary(self): return self.prefetch_related( 'segment_set__job_set', ).annotate( + total_jobs_count=models.Count('segment__job', distinct=True), completed_jobs_count=models.Count( 'segment__job', filter=models.Q(segment__job__state=StateChoice.COMPLETED.value) & diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 1c7db81b527..cbd149b82d5 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -141,60 +141,20 @@ def bind(self, field_name, parent): def get_fields(self): fields = super().get_fields() fields['url'] = HyperlinkedEndpointSerializer(self._model, filter_key=self._url_filter_key) - fields['count'].source = self._collection_key + '.count' + if not fields['count'].source: + fields['count'].source = self._collection_key + '.count' return fields def get_attribute(self, instance): return instance -class JobsSummarySerializer(serializers.Serializer): - url = serializers.URLField(read_only=True) - count = serializers.IntegerField(read_only=True) - completed = serializers.IntegerField(read_only=True) - validation = serializers.IntegerField(read_only=True) +class JobsSummarySerializer(_CollectionSummarySerializer): + count = serializers.IntegerField(source='total_jobs_count', allow_null=True) + completed = serializers.IntegerField(source='completed_jobs_count', allow_null=True) + validation = serializers.IntegerField(source='validation_jobs_count', allow_null=True) def __init__(self, *, model=models.Job, url_filter_key, **kwargs): - super().__init__(**kwargs) - self._model = model - self._url_filter_key = url_filter_key - - def get_url(self, request, instance): - return reverse( - "job-list", request=request, query_params={"task_id": instance.id} - ) - - def get_counts(self, instance): - jobs = [ - j - for j in list( - self._model.objects.filter(segment__task=instance) - ) - ] - return { - "count": len(jobs), - "completed": len( - [ - j - for j in jobs - if j.status == models.StatusChoice.COMPLETED.value - and j.stage == models.StageChoice.ACCEPTANCE.value - ] - ), - "validation": len( - [j for j in jobs if j.stage == models.StageChoice.VALIDATION.value] - ), - } - - def to_representation(self, instance): - request = self.context.get("request") - if not request: - return None - - - representation = self.get_counts(instance) - representation["url"] = self.get_url(request, instance) - - return representation + super().__init__(model=model, url_filter_key=url_filter_key, **kwargs) class TasksSummarySerializer(_CollectionSummarySerializer): @@ -1158,7 +1118,7 @@ class TaskReadSerializer(serializers.ModelSerializer): dimension = serializers.CharField(allow_blank=True, required=False) target_storage = StorageSerializer(required=False, allow_null=True) source_storage = StorageSerializer(required=False, allow_null=True) - jobs = JobsSummarySerializer(url_filter_key='task_id', source="*") + jobs = JobsSummarySerializer(url_filter_key='task_id', source='segment_set') labels = LabelsSummarySerializer(source='*') consensus_jobs_per_regular_job = serializers.ReadOnlyField(required=False, allow_null=True) From c186d67668d589b3210adb59548a58cac463f985 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 7 Aug 2024 01:12:34 +0530 Subject: [PATCH 129/301] removed unwanted comments --- tests/python/rest_api/test_tasks.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index ec4c9da1ea5..1e39f49463e 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -2316,8 +2316,6 @@ def _test_can_restore_backup_task(self, task_id: int): old_jobs = task.get_jobs() new_jobs = restored_task.get_jobs() assert len(old_jobs) == len(new_jobs) - # print(old_jobs) - # print(new_jobs) for old_job, new_job in zip(old_jobs, new_jobs): assert old_job.status == new_job.status From 93d0758d72252fc7c5dcb9aad6be3655b446b229 Mon Sep 17 00:00:00 2001 From: vidit Date: Wed, 7 Aug 2024 01:14:05 +0530 Subject: [PATCH 130/301] count param in `tasks.json` for testing updated --- tests/python/shared/assets/tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/shared/assets/tasks.json b/tests/python/shared/assets/tasks.json index ac69ba3b72f..7e117e61553 100644 --- a/tests/python/shared/assets/tasks.json +++ b/tests/python/shared/assets/tasks.json @@ -19,7 +19,7 @@ "image_quality": 70, "jobs": { "completed": 0, - "count": 1, + "count": 4, "url": "http://localhost:8080/api/jobs?task_id=26", "validation": 0 }, From f7c20a06460ad75f9618f88d92dbc7227bf573ca Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 9 Aug 2024 01:08:32 +0530 Subject: [PATCH 131/301] added target param while filtering consensus report --- cvat/apps/consensus/views.py | 46 +++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/cvat/apps/consensus/views.py b/cvat/apps/consensus/views.py index c3bd73b963e..92f5fba285a 100644 --- a/cvat/apps/consensus/views.py +++ b/cvat/apps/consensus/views.py @@ -22,7 +22,12 @@ from cvat.apps.consensus.consensus_reports import prepare_report_for_downloading from cvat.apps.consensus.merge_consensus_jobs import merge_task -from cvat.apps.consensus.models import ConsensusConflict, ConsensusReport, ConsensusSettings +from cvat.apps.consensus.models import ( + ConsensusConflict, + ConsensusReport, + ConsensusReportTarget, + ConsensusSettings, +) from cvat.apps.consensus.permissions import ( ConsensusConflictPermission, ConsensusReportPermission, @@ -39,23 +44,6 @@ from cvat.apps.engine.serializers import RqIdSerializer from cvat.apps.engine.utils import get_server_url -""" -engine> views.py> TaskViewSet - -For now that's fine, but it should return `rq_id` - -In this views.py we can get details on merge report. - -storing merge report as `.json` like analytics report or quality report [prefered] -like a string only a parameter model - -or somewhat like storing report attributes. - -/aggregate/ => list of merge reports -/aggregate/settings/ - -""" - @extend_schema(tags=["consensus"]) @extend_schema_view( @@ -121,7 +109,12 @@ def get_queryset(self): self.check_object_permissions(self.request, report) - queryset = queryset.filter(report=report) + if report.target == ConsensusReportTarget.TASK: + queryset = queryset.filter(Q(report=report)).distinct() + elif report.target == ConsensusReportTarget.JOB: + queryset = queryset.filter(report=report) + else: + assert False else: perm = ConsensusConflictPermission.create_scope_list(self.request) queryset = perm.filter(queryset) @@ -145,6 +138,9 @@ def get_queryset(self): OpenApiParameter( "task_id", type=OpenApiTypes.INT, description="A simple equality filter for task id" ), + OpenApiParameter( + "target", type=OpenApiTypes.STR, description="A simple equality filter for target" + ), ], responses={ "200": ConsensusReportSerializer(many=True), @@ -203,6 +199,18 @@ def get_queryset(self): perm = ConsensusReportPermission.create_scope_list(self.request) queryset = perm.filter(queryset) + if target := self.request.query_params.get("target", None): + if target == ConsensusReportTarget.JOB: + queryset = queryset.filter(job__isnull=False) + elif target == ConsensusReportTarget.TASK: + queryset = queryset.filter(job__isnull=True) + else: + raise ValidationError( + "Unexpected 'target' filter value '{}'. Valid values are: {}".format( + target, ", ".join(m[0] for m in ConsensusReportTarget.choices()) + ) + ) + return queryset CREATE_REPORT_RQ_ID_PARAMETER = "rq_id" From d922b8f76ce4b8c31546421ea80708822b88d78a Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 9 Aug 2024 01:08:54 +0530 Subject: [PATCH 132/301] added target and assignee in consensus reports --- cvat/apps/consensus/serializers.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cvat/apps/consensus/serializers.py b/cvat/apps/consensus/serializers.py index c5f32010ed6..55208aa472f 100644 --- a/cvat/apps/consensus/serializers.py +++ b/cvat/apps/consensus/serializers.py @@ -4,12 +4,11 @@ import textwrap -from django.db import IntegrityError, models, transaction from rest_framework import serializers from cvat.apps.consensus import models from cvat.apps.consensus.models import AnnotationId -from cvat.apps.engine.models import Task +from cvat.apps.engine import serializers as engine_serializers class ConsensusAnnotationIdSerializer(serializers.ModelSerializer): @@ -42,6 +41,8 @@ class ConsensusReportSummarySerializer(serializers.Serializer): class ConsensusReportSerializer(serializers.ModelSerializer): + target = serializers.ChoiceField(models.ConsensusReportTarget.choices()) + assignee = engine_serializers.BasicUserSerializer(allow_null=True, read_only=True) summary = ConsensusReportSummarySerializer() class Meta: @@ -53,6 +54,9 @@ class Meta: "summary", "created_date", "target_last_updated", + "target", + "assignee", + "consensus_score", ) read_only_fields = fields From ca4d4f29d167991527c02c2b01ee5a9d8161056c Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 9 Aug 2024 01:09:08 +0530 Subject: [PATCH 133/301] added consensus migrations --- .../apps/consensus/migrations/0001_initial.py | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/cvat/apps/consensus/migrations/0001_initial.py b/cvat/apps/consensus/migrations/0001_initial.py index 54f6474cc98..582c815d820 100644 --- a/cvat/apps/consensus/migrations/0001_initial.py +++ b/cvat/apps/consensus/migrations/0001_initial.py @@ -1,6 +1,7 @@ -# Generated by Django 4.2.13 on 2024-07-28 09:18 +# Generated by Django 4.2.13 on 2024-08-08 19:15 import django.db.models.deletion +from django.conf import settings from django.db import migrations, models @@ -9,6 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("engine", "0082_job_parent_job_id_and_more"), ] @@ -49,7 +51,18 @@ class Migration(migrations.Migration): ), ("created_date", models.DateTimeField(auto_now_add=True)), ("target_last_updated", models.DateTimeField()), + ("consensus_score", models.IntegerField()), ("data", models.JSONField()), + ( + "assignee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="consensus", + to=settings.AUTH_USER_MODEL, + ), + ), ( "job", models.ForeignKey( @@ -86,12 +99,12 @@ class Migration(migrations.Migration): "type", models.CharField( choices=[ - ("NO_MATCHING_ITEM", "NoMatchingItemError"), - ("FAILED_ATTRIBUTE_VOTING", "FailedAttrVotingError"), - ("NO_MATCHING_ANNOTATION", "NoMatchingAnnError"), - ("ANNOTATION_TOO_CLOSE", "AnnotationsTooCloseError"), - ("WRONG_GROUP", "WrongGroupError"), - ("FAILED_LABEL_VOTING", "FailedLabelVotingError"), + ("no_matching_item", "NoMatchingItemError"), + ("failed_attribute_voting", "FailedAttrVotingError"), + ("no_matching_annotation", "NoMatchingAnnError"), + ("annotation_too_close", "AnnotationsTooCloseError"), + ("wrong_group", "WrongGroupError"), + ("failed_label_voting", "FailedLabelVotingError"), ], max_length=32, ), From c240ee7d49a8bfb59d57ab6b46d236fdac0d8648 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 9 Aug 2024 01:10:30 +0530 Subject: [PATCH 134/301] reformatting and removed unused imports --- cvat/apps/consensus/apps.py | 2 +- cvat/apps/consensus/consensus_reports.py | 72 ++++----------------- cvat/apps/consensus/merge_consensus_jobs.py | 44 ++++++------- cvat/apps/consensus/models.py | 32 ++++++--- cvat/apps/consensus/new_intersect_merge.py | 52 ++++++--------- cvat/apps/consensus/permissions.py | 3 +- cvat/apps/consensus/signals.py | 3 +- cvat/apps/consensus/urls.py | 2 +- 8 files changed, 80 insertions(+), 130 deletions(-) diff --git a/cvat/apps/consensus/apps.py b/cvat/apps/consensus/apps.py index 62d8500483f..a3504da9c0f 100644 --- a/cvat/apps/consensus/apps.py +++ b/cvat/apps/consensus/apps.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023-2024 CVAT.ai Corporation +# Copyright (C) 2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT diff --git a/cvat/apps/consensus/consensus_reports.py b/cvat/apps/consensus/consensus_reports.py index 2948ffcb184..f204cbe4ac1 100644 --- a/cvat/apps/consensus/consensus_reports.py +++ b/cvat/apps/consensus/consensus_reports.py @@ -5,26 +5,19 @@ from __future__ import annotations import itertools -import math from collections import Counter from copy import deepcopy -from datetime import timedelta -from functools import cached_property, partial +from functools import cached_property from types import NoneType -from typing import Any, Callable, Dict, Hashable, List, Optional, Sequence, Tuple, Union, cast -from uuid import uuid4 +from typing import Any, Dict, List, Optional, cast import datumaro as dm -import datumaro.util.mask_tools -import django_rq import numpy as np -from attrs import asdict, define, fields_dict +from attrs import define, fields_dict from datumaro.components.annotation import Annotation from datumaro.util import dump_json, parse_json -from django.conf import settings from django.db import transaction from django.utils import timezone -from scipy.optimize import linear_sum_assignment from cvat.apps.consensus import models from cvat.apps.consensus.models import ( @@ -33,28 +26,9 @@ ConsensusReport, ConsensusSettings, ) -from cvat.apps.dataset_manager.bindings import ( - CommonData, - CvatToDmAnnotationConverter, - GetCVATDataExtractor, - JobData, - match_dm_item, -) -from cvat.apps.dataset_manager.formats.registry import dm_env -from cvat.apps.dataset_manager.task import JobAnnotation from cvat.apps.dataset_manager.util import bulk_create -from cvat.apps.engine.models import ( - DimensionType, - Job, - JobType, - ShapeType, - StageChoice, - StatusChoice, - Task, -) -from cvat.apps.profiler import silk_profile +from cvat.apps.engine.models import Job, Task from cvat.apps.quality_control.quality_reports import AnnotationId, JobDataProvider, _Serializable -from cvat.utils.background_jobs import schedule_job_with_throttling @define(kw_only=True) @@ -181,19 +155,16 @@ def from_dict(cls, d: dict): @define(kw_only=True) class ComparisonParameters(_Serializable): - # TODO: dm.AnnotationType.skeleton to be implemented included_annotation_types: List[dm.AnnotationType] = [ dm.AnnotationType.bbox, dm.AnnotationType.points, dm.AnnotationType.mask, dm.AnnotationType.polygon, dm.AnnotationType.polyline, + dm.AnnotationType.skeleton, dm.AnnotationType.label, ] - # non_groupable_ann_type = dm.AnnotationType.label - # "Annotation type that can't be grouped" - agreement_score_threshold: float quorum: int iou_threshold: float @@ -222,7 +193,7 @@ def conflicts(self) -> List[AnnotationConflict]: return list(itertools.chain.from_iterable(r.conflicts for r in self.frame_results.values())) @property - def consensus_score(self) -> float: + def consensus_score(self) -> int: mean_consensus_score = 0 frame_count = 0 for frame_result in self.frame_results.values(): @@ -230,7 +201,7 @@ def consensus_score(self) -> float: mean_consensus_score += frame_result.consensus_score frame_count += 1 - return mean_consensus_score / (frame_count or 1) + return np.round(100 * (mean_consensus_score / (frame_count or 1))) def _fields_dict(self, *, include_properties: Optional[List[str]] = None) -> dict: return super()._fields_dict( @@ -337,7 +308,7 @@ def generate_task_consensus_report(job_reports: List[ComparisonReport]) -> Compa task_conflicts: List[AnnotationConflict] = [] task_frame_results = {} task_frame_results_counts = {} - for r in job_reports.values(): + for r in job_reports: task_frames.update(r.comparison_summary.frames) task_conflicts.extend(r.conflicts) @@ -360,7 +331,7 @@ def generate_task_consensus_report(job_reports: List[ComparisonReport]) -> Compa task_frame_results[frame_id] = task_frame_result task_report_data = ComparisonReport( - parameters=next(iter(job_reports.values())).parameters, + parameters=job_reports[0].parameters, comparison_summary=ComparisonReportComparisonSummary( frames=sorted(task_frames), conflict_count=len(task_conflicts), @@ -371,19 +342,12 @@ def generate_task_consensus_report(job_reports: List[ComparisonReport]) -> Compa return task_report_data -def get_last_report_time(task: Task) -> Optional[timezone.datetime]: - report = models.ConsensusReport.objects.filter(task=task).order_by("-created_date").first() - if report: - return report.created_date - return None - - @transaction.atomic def save_report( task_id: int, jobs: List[Job], task_report_data: ComparisonReport, - job_report_data: List[ComparisonReport], + job_report_data: Dict[int, ComparisonReport], ): try: Task.objects.get(id=task_id) @@ -392,21 +356,11 @@ def save_report( task = Task.objects.filter(id=task_id).first() - # last_report_time = self._get_last_report_time(task) - # if not self.is_custom_quality_check_job(self._get_current_job()) and ( - # last_report_time - # and timezone.now() < last_report_time + self._get_quality_check_job_delay() - # ): - # # Discard this report as it has probably been computed in parallel - # # with another one - # return - mean_consensus_score = 0 job_reports = {} - for job_id in jobs: - job_comparison_report = job_report_data[job_id] - job = Job.objects.filter(id=job_id).first() + for job in jobs: + job_comparison_report = job_report_data[job.id] job_consensus_score = job_comparison_report.consensus_score job_report = dict( job=job, @@ -444,6 +398,7 @@ def save_report( db_job_reports = [] for job_report in job_reports: db_job_report = ConsensusReport( + task=task_report["task"], job=job_report["job"], target_last_updated=job_report["target_last_updated"], data=job_report["data"], @@ -490,6 +445,7 @@ def save_report( def prepare_report_for_downloading(db_report: ConsensusReport, *, host: str) -> str: + # copied from quality_reports.py # Decorate the report for better usability and readability: # - add conflicting annotation links like: # /tasks/62/jobs/82?frame=250&type=shape&serverID=33741 diff --git a/cvat/apps/consensus/merge_consensus_jobs.py b/cvat/apps/consensus/merge_consensus_jobs.py index 114af4b4b3c..2b35812e06d 100644 --- a/cvat/apps/consensus/merge_consensus_jobs.py +++ b/cvat/apps/consensus/merge_consensus_jobs.py @@ -2,14 +2,12 @@ # # SPDX-License-Identifier: MIT -from typing import Dict, List -from uuid import uuid4 +from typing import Dict, List, Tuple import datumaro as dm import django_rq from django.conf import settings from django.db import transaction -from django.utils import timezone from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.response import Response @@ -27,7 +25,6 @@ from cvat.apps.dataset_manager.bindings import import_dm_annotations from cvat.apps.dataset_manager.task import PatchAction, patch_job_data from cvat.apps.engine.models import Job, JobType, StageChoice, StateChoice, Task -from cvat.apps.engine.serializers import RqIdSerializer from cvat.apps.engine.utils import ( define_dependent_job, get_rq_job_meta, @@ -37,8 +34,8 @@ from cvat.apps.quality_control.quality_reports import JobDataProvider -def get_consensus_jobs(task_id: int) -> Dict[int, List[int]]: - jobs = {} # parent_job_id -> [consensus_job_id] +def get_consensus_jobs(task_id: int) -> Tuple[Dict[int, List[int]], List[Job]]: + jobs = {} # parent_job_id -> [(consensus_job_id, assignee)] for job in Job.objects.select_related("segment").filter( segment__task_id=task_id, type=JobType.CONSENSUS.value @@ -48,29 +45,31 @@ def get_consensus_jobs(task_id: int) -> Dict[int, List[int]]: # if the job is in NEW state, it means that the job isn't annotated if job.state == StateChoice.NEW.value: continue - jobs.setdefault(job.parent_job_id, []).append(job.id) + jobs.setdefault(job.parent_job_id, []).append((job.id, job.assignee_id)) parent_job_ids = list(jobs.keys()) + parent_jobs: List[Job] = [] + # remove parent jobs that are not in annotation stage for parent_job_id in parent_job_ids: - if ( - Job.objects.filter(id=parent_job_id, type=JobType.ANNOTATION.value).first().stage - == StageChoice.ANNOTATION.value - ): + parent_job = Job.objects.filter(id=parent_job_id, type=JobType.ANNOTATION.value).first() + parent_jobs.append(parent_job) + + if parent_job.stage == StageChoice.ANNOTATION.value: continue else: jobs.pop(parent_job_id) - return jobs + return jobs, parent_jobs def get_annotations(job_id: int) -> dm.Dataset: - return JobDataProvider(job_id).dm_dataset # .get("08122008671") + return JobDataProvider(job_id).dm_dataset @transaction.atomic def _merge_consensus_jobs(task_id: int) -> None: - jobs = get_consensus_jobs(task_id) + jobs, parent_jobs = get_consensus_jobs(task_id) if not jobs: raise ValidationError( "No annotated consensus jobs found or no regular jobs in annotation stage" @@ -88,7 +87,8 @@ def _merge_consensus_jobs(task_id: int) -> None: job_comparison_reports: Dict[int, ComparisonReport] = {} - for parent_job_id, job_ids in jobs.items(): + for parent_job_id, job_info in jobs.items(): + job_ids = [job_id for job_id, _ in job_info] consensus_job_data_providers = list(map(JobDataProvider, job_ids)) consensus_datasets = [ consensus_job_data_provider.dm_dataset @@ -101,7 +101,7 @@ def _merge_consensus_jobs(task_id: int) -> None: patch_job_data(parent_job_id, None, PatchAction.DELETE) # if we don't delete exising annotations, the imported annotations # will be appended to the existing annotations, and thus updated annotation - # would have both exisiting + imported annotations, but we only want the + # would have both existing + imported annotations, but we only want the # imported annotations parent_job = JobDataProvider(parent_job_id) @@ -122,8 +122,8 @@ def _merge_consensus_jobs(task_id: int) -> None: parent_job.state = StateChoice.COMPLETED.value parent_job.save() - task_report_data = generate_task_consensus_report(job_comparison_reports) - return save_report(task_id, jobs, task_report_data, job_comparison_reports) + task_report_data = generate_task_consensus_report(list(job_comparison_reports.values())) + return save_report(task_id, parent_jobs, task_report_data, job_comparison_reports) def merge_task(task: Task, request) -> Response: @@ -138,16 +138,13 @@ def merge_task(task: Task, request) -> Response: if rq_job.is_finished: # returned_data = rq_job.return_value() rq_job.delete() - return Response( - status=status.HTTP_201_CREATED - ) # if returned_data == 201 else Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_201_CREATED) elif rq_job.is_failed: exc_info = process_failed_job(rq_job) return Response(data=exc_info, status=status.HTTP_500_INTERNAL_SERVER_ERROR) else: # rq_job is in queued stage or might be running return Response(status=status.HTTP_202_ACCEPTED) - # return Response(serializer.data, status=status.HTTP_202_ACCEPTED) func = _merge_consensus_jobs func_args = [task.id] @@ -162,6 +159,3 @@ def merge_task(task: Task, request) -> Response: ) return rq_id - # serializer = RqIdSerializer(data={'rq_id': rq_id}) - # serializer.is_valid(raise_exception=True) - # return Response(serializer.data, status=status.HTTP_202_ACCEPTED) diff --git a/cvat/apps/consensus/models.py b/cvat/apps/consensus/models.py index 5d52236b107..1d7fcfa0592 100644 --- a/cvat/apps/consensus/models.py +++ b/cvat/apps/consensus/models.py @@ -4,18 +4,9 @@ from __future__ import annotations # this allows forward references -from copy import deepcopy from enum import Enum from typing import Any, Sequence -from datumaro.components.errors import ( - AnnotationsTooCloseError, - FailedAttrVotingError, - FailedLabelVotingError, - NoMatchingAnnError, - NoMatchingItemError, - WrongGroupError, -) from django.core.exceptions import ValidationError from django.db import models from django.forms.models import model_to_dict @@ -52,6 +43,18 @@ def choices(cls): return tuple((x.value, x.name) for x in cls) +class ConsensusReportTarget(str, Enum): + JOB = "job" + TASK = "task" + + def __str__(self) -> str: + return self.value + + @classmethod + def choices(cls): + return tuple((x.value, x.name) for x in cls) + + class ConsensusSettings(models.Model): task = models.ForeignKey( Task, @@ -97,7 +100,7 @@ class ConsensusReport(models.Model): created_date = models.DateTimeField(auto_now_add=True) target_last_updated = models.DateTimeField() - consensus_score = models.FloatField() + consensus_score = models.IntegerField() assignee = models.ForeignKey( User, on_delete=models.SET_NULL, related_name="consensus", null=True, blank=True ) @@ -116,6 +119,15 @@ def summary(self): report = self._parse_report() return report.comparison_summary + @property + def target(self) -> ConsensusReportTarget: + if self.job: + return ConsensusReportTarget.JOB + elif self.task: + return ConsensusReportTarget.TASK + else: + assert False + def get_task(self) -> Task: if self.task is not None: return self.task diff --git a/cvat/apps/consensus/new_intersect_merge.py b/cvat/apps/consensus/new_intersect_merge.py index 69269915678..acc18b2790b 100644 --- a/cvat/apps/consensus/new_intersect_merge.py +++ b/cvat/apps/consensus/new_intersect_merge.py @@ -1,5 +1,4 @@ -# Copyright (C) 2020-2022 Intel Corporation -# Copyright (C) 2022 CVAT.ai Corporation +# Copyright (C) 2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -7,22 +6,7 @@ import logging as log import math from collections import OrderedDict -from copy import deepcopy -from functools import cached_property, partial -from typing import ( - Any, - Callable, - Dict, - Hashable, - Iterable, - List, - Optional, - Sequence, - Tuple, - Type, - Union, - cast, -) +from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Type, Union, cast import attr import datumaro as dm @@ -57,15 +41,9 @@ ) from datumaro.components.extractor import CategoriesInfo, DatasetItem from datumaro.components.media import Image, MediaElement, MultiframeImage, PointCloud, Video -from datumaro.util import filter_dict, find -from datumaro.util.annotation_util import ( - approximate_line, - bbox_iou, - find_instances, - max_bbox, - mean_bbox, -) -from datumaro.util.attrs_util import default_if_none, ensure_cls +from datumaro.util import find +from datumaro.util.annotation_util import find_instances, max_bbox, mean_bbox +from datumaro.util.attrs_util import ensure_cls from scipy.optimize import linear_sum_assignment @@ -531,6 +509,7 @@ def add_item_error(self, error, *args, **kwargs): _ann_map = attrib(init=False) # id(ann) -> (ann, id(item)) _item_id = attrib(init=False) _item = attrib(init=False) + _dataset_mean_consensus_score = attrib(init=False) # id(dataset) -> mean consensus score: float # Misc. _categories = attrib(init=False) # merged categories @@ -546,6 +525,7 @@ def __call__(self, datasets): item_matches, item_map = self.match_items(datasets) self._item_map = item_map + self._dataset_mean_consensus_score = {id(d): [] for d in datasets} self._dataset_map = {id(d): (d, i) for i, d in enumerate(datasets)} for item_id, items in item_matches.items(): @@ -557,6 +537,12 @@ def __call__(self, datasets): self.add_item_error(NoMatchingItemError, sources=missing_sources) merged.put(self.merge_items(items)) + # now we have conensus score for all annotations in + for dataset_id in self._dataset_mean_consensus_score: + self._dataset_mean_consensus_score[dataset_id] = np.mean( + self._dataset_mean_consensus_score[dataset_id] + ) + return merged def get_ann_source(self, ann_id): @@ -1035,6 +1021,7 @@ class _ShapeMatcher(AnnotationMatcher): cluster_dist = attrib(converter=float, default=-1.0) return_distances = False categories = attrib(converter=dict, default={}) + distance_index = attrib(converter=dict, default={}) def _instance_bbox( self, instance_anns: Sequence[dm.Annotation] @@ -1846,15 +1833,18 @@ def _merge_cluster_shape_mean_box_nearest(self, cluster): dataitem_id = self._context._ann_map[id(a)][1] img_h, img_w = self._context._item_map[dataitem_id][0].image.size dist = list(segment_iou(mbbox, s, img_h=img_h, img_w=img_w) for s in cluster) - # print(cluster) - # print(mbbox, dist) nearest_pos, _ = max(enumerate(dist), key=lambda e: e[1]) - # print(nearest_pos, dist[nearest_pos], cluster[nearest_pos]) return cluster[nearest_pos] def merge_cluster_shape(self, cluster): shape = self._merge_cluster_shape_mean_box_nearest(cluster) - shape_score = sum(max(0, self.distance(shape, s)) for s in cluster) / len(cluster) + distance, _ = self._make_memoizing_distance(self.distance) + for ann in cluster: + dataset_id = self._context._item_map[self._context._ann_map[id(ann)][1]][1] + self._context._dataset_mean_consensus_score.setdefault(dataset_id, []).append( + sum(max(0, distance(ann, s)) for s in cluster) / len(cluster) + ) + shape_score = sum(max(0, distance(shape, s)) for s in cluster) / len(cluster) return shape, shape_score def merge_cluster(self, cluster): diff --git a/cvat/apps/consensus/permissions.py b/cvat/apps/consensus/permissions.py index b87d0446401..8b47a9455a5 100644 --- a/cvat/apps/consensus/permissions.py +++ b/cvat/apps/consensus/permissions.py @@ -1,5 +1,4 @@ -# Copyright (C) 2022 Intel Corporation -# Copyright (C) 2022-2024 CVAT.ai Corporation +# Copyright (C) 2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT diff --git a/cvat/apps/consensus/signals.py b/cvat/apps/consensus/signals.py index be161a80c5c..e717c5480f5 100644 --- a/cvat/apps/consensus/signals.py +++ b/cvat/apps/consensus/signals.py @@ -2,13 +2,12 @@ # # SPDX-License-Identifier: MIT -from django.db import transaction from django.db.models.signals import post_save from django.dispatch import receiver # from cvat.apps.quality_control import quality_reports as qc from cvat.apps.consensus.models import ConsensusSettings -from cvat.apps.engine.models import Annotation, Job, Project, Task +from cvat.apps.engine.models import Job, Task @receiver( diff --git a/cvat/apps/consensus/urls.py b/cvat/apps/consensus/urls.py index eedd62c7a70..d44e339e546 100644 --- a/cvat/apps/consensus/urls.py +++ b/cvat/apps/consensus/urls.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 CVAT.ai Corporation +# Copyright (C) 2024 CVAT.ai Corporation # # SPDX-License-Identifier: MIT From 0b428f872115325dfe213a065db2490ee8f7fa5a Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 9 Aug 2024 01:12:14 +0530 Subject: [PATCH 135/301] updated implementation of count of jobs in task details --- cvat/apps/engine/models.py | 1 + cvat/apps/engine/serializers.py | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 96994f0ee6f..44a41efadfe 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -380,6 +380,7 @@ def with_job_summary(self): return self.prefetch_related( 'segment_set__job_set', ).annotate( + total_jobs_count=models.Count('segment__job', distinct=True), completed_jobs_count=models.Count( 'segment__job', filter=models.Q(segment__job__state=StateChoice.COMPLETED.value) & diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index f739f0194ea..340e72682d8 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -148,6 +148,7 @@ def get_attribute(self, instance): return instance class JobsSummarySerializer(_CollectionSummarySerializer): + count = serializers.IntegerField(source='total_jobs_count', allow_null=True) completed = serializers.IntegerField(source='completed_jobs_count', allow_null=True) validation = serializers.IntegerField(source='validation_jobs_count', allow_null=True) @@ -611,7 +612,7 @@ class Meta: 'dimension', 'bug_tracker', 'status', 'stage', 'state', 'mode', 'frame_count', 'start_frame', 'stop_frame', 'data_chunk_size', 'data_compressed_chunk_type', 'created_date', 'updated_date', 'issues', 'labels', 'type', 'organization', - 'target_storage', 'source_storage', 'parent_job_id', 'assignee_updated_date') + 'target_storage', 'source_storage', 'assignee_updated_date', 'parent_job_id') read_only_fields = fields def to_representation(self, instance): @@ -1340,8 +1341,8 @@ def validate(self, attrs): consensus_jobs_per_regular_job = attrs.get('consensus_jobs_per_regular_job', self.instance.consensus_jobs_per_regular_job if self.instance else None) - if consensus_jobs_per_regular_job and (consensus_jobs_per_regular_job == 1 or consensus_jobs_per_regular_job < 0): - raise serializers.ValidationError("Consensus jobs per regular job should be greater than or equal to 0 and not 1") + if consensus_jobs_per_regular_job and (consensus_jobs_per_regular_job == 1 or consensus_jobs_per_regular_job < 0 or consensus_jobs_per_regular_job > 10): + raise serializers.ValidationError("Consensus jobs per regular job shouldn't be negative, less than 10 except 1") return attrs From 26a7157659c38f0b88b43e3cba08a4ce61f411df Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 9 Aug 2024 01:13:13 +0530 Subject: [PATCH 136/301] updated shade of yellow color in quality and consensus color --- cvat-ui/src/utils/quality-color.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvat-ui/src/utils/quality-color.ts b/cvat-ui/src/utils/quality-color.ts index 5373a1457c7..ab809817bcf 100644 --- a/cvat-ui/src/utils/quality-color.ts +++ b/cvat-ui/src/utils/quality-color.ts @@ -4,7 +4,7 @@ enum QualityColors { GREEN = '#237804', - YELLOW = '#ffec3d', + YELLOW = '#cebe1e', RED = '#ff4d4f', GRAY = '#8c8c8c', } From 2af30bacc786b17a4a795aa02e041524880746c6 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 9 Aug 2024 01:15:30 +0530 Subject: [PATCH 137/301] renamed QualityConflictsFilter to ConflictsFilter --- cvat-core/src/quality-conflict.ts | 6 +++--- cvat-core/src/server-response-types.ts | 17 ++++------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/cvat-core/src/quality-conflict.ts b/cvat-core/src/quality-conflict.ts index 3d7252f37e8..7b107c8c836 100644 --- a/cvat-core/src/quality-conflict.ts +++ b/cvat-core/src/quality-conflict.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -import { SerializedAnnotationConflictData, SerializedQualityConflictData } from './server-response-types'; +import { SerializedAnnotationQualityConflictData, SerializedQualityConflictData } from './server-response-types'; import { ObjectType } from './enums'; export enum QualityConflictType { @@ -25,7 +25,7 @@ export class AnnotationConflict { #severity: ConflictSeverity; #description: string; - constructor(initialData: SerializedAnnotationConflictData) { + constructor(initialData: SerializedAnnotationQualityConflictData) { this.#jobID = initialData.job_id; this.#serverID = initialData.obj_id; this.#type = initialData.type; @@ -80,7 +80,7 @@ export default class QualityConflict { this.#type = initialData.type as QualityConflictType; this.#severity = initialData.severity as ConflictSeverity; this.#annotationConflicts = initialData.annotation_ids - .map((rawData: SerializedAnnotationConflictData) => new AnnotationConflict({ + .map((rawData: SerializedAnnotationQualityConflictData) => new AnnotationConflict({ ...rawData, conflict_type: initialData.type, severity: initialData.severity, diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index 77d465e701f..855eeabfa13 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -257,21 +257,12 @@ export interface SerializedQualitySettingsData { compare_attributes?: boolean; } -export interface SerializedConsensusSettingsData { - id?: number; - task?: number; - agreement_score_threshold?: number; - quorum?: number; - iou_threshold?: number; - sigma?: number; -} - -export interface APIQualityConflictsFilter extends APICommonFilterParams { +export interface APIConflictsFilter extends APICommonFilterParams { report_id?: number; } -export type QualityConflictsFilter = Camelized; +export type ConflictsFilter = Camelized; -export interface SerializedAnnotationConflictData { +export interface SerializedAnnotationQualityConflictData { job_id?: number; obj_id?: number; type?: ObjectType; @@ -284,7 +275,7 @@ export interface SerializedQualityConflictData { id?: number; frame?: number; type?: string; - annotation_ids?: SerializedAnnotationConflictData[]; + annotation_ids?: SerializedAnnotationQualityConflictData[]; data?: string; severity?: string; description?: string; From 13e9edb37290f4dc61d9bcbe6753b0ed0b6054f8 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 9 Aug 2024 01:17:28 +0530 Subject: [PATCH 138/301] added interface for Consensus Reports, Settings and Conflict --- cvat-core/src/api-implementation.ts | 105 ++++++++++++++++++++++++- cvat-core/src/api.ts | 8 ++ cvat-core/src/index.ts | 10 ++- cvat-core/src/server-proxy.ts | 44 ++++++++++- cvat-core/src/server-response-types.ts | 56 +++++++++++++ cvat-ui/src/cvat-core-wrapper.ts | 2 + 6 files changed, 218 insertions(+), 7 deletions(-) diff --git a/cvat-core/src/api-implementation.ts b/cvat-core/src/api-implementation.ts index a6b08973f6c..dd2231f097d 100644 --- a/cvat-core/src/api-implementation.ts +++ b/cvat-core/src/api-implementation.ts @@ -31,8 +31,9 @@ import Organization, { Invitation } from './organization'; import Webhook from './webhook'; import { ArgumentError } from './exceptions'; import { - AnalyticsReportFilter, QualityConflictsFilter, QualityReportsFilter, + AnalyticsReportFilter, ConflictsFilter, QualityReportsFilter, SettingsFilter, SerializedAsset, + ConsensusReportsFilter, } from './server-response-types'; import QualityReport from './quality-report'; import QualityConflict, { ConflictSeverity } from './quality-conflict'; @@ -44,6 +45,8 @@ import { JobType } from './enums'; import { PaginatedResource } from './core-types'; import CVATCore from '.'; import ConsensusSettings from './consensus-settings'; +import ConsensusReport from './consensus-report'; +import ConsensusConflict from './consensus-conflict'; function implementationMixin(func: Function, implementation: Function): void { Object.assign(func, { implementation }); @@ -435,7 +438,7 @@ export default function implementAPI(cvat: CVATCore): CVATCore { ); return reports; }); - implementationMixin(cvat.analytics.quality.conflicts, async (filter: QualityConflictsFilter) => { + implementationMixin(cvat.analytics.quality.conflicts, async (filter: ConflictsFilter) => { checkFilter(filter, { reportID: isInteger, }); @@ -521,6 +524,29 @@ export default function implementAPI(cvat: CVATCore): CVATCore { const settings = await serverProxy.analytics.quality.settings.get(params); return new QualitySettings({ ...settings }); }); + implementationMixin(cvat.consensus.reports, async (filter: ConsensusReportsFilter) => { + checkFilter(filter, { + page: isInteger, + pageSize: isPageSize, + projectID: isInteger, + taskID: isInteger, + jobID: (value: any) => isInteger(value) || value === null, + filter: isString, + search: isString, + target: isString, + sort: isString, + }); + + const params = fieldsToSnakeCase({ ...filter, sort: '-created_date' }); + + const reportsData = await serverProxy.consensus.reports(params); + console.log(reportsData); + const reports = Object.assign( + reportsData.map((report) => new ConsensusReport({ ...report })), + { count: reportsData.count }, + ); + return reports; + }); implementationMixin(cvat.consensus.settings.get, async (filter: SettingsFilter) => { checkFilter(filter, { taskID: isInteger, @@ -546,6 +572,81 @@ export default function implementAPI(cvat: CVATCore): CVATCore { const reportData = await serverProxy.analytics.performance.reports(params); return new AnalyticsReport(reportData); }); + implementationMixin(cvat.consensus.conflicts, async (filter: ConflictsFilter) => { + checkFilter(filter, { + reportID: isInteger, + }); + + const params = fieldsToSnakeCase(filter); + + const conflictsData = await serverProxy.consensus.conflicts(params); + const conflicts = conflictsData.map((conflict) => new ConsensusConflict({ ...conflict })); + const frames = Array.from(new Set(conflicts.map((conflict) => conflict.frame))) + .sort((a, b) => a - b); + + // each ConsensusConflict may have several AnnotationConflicts bound + // at the same time, many quality conflicts may refer + // to the same labeled object (e.g. mismatch label, low overlap) + // the code below unites quality conflicts bound to the same object into one QualityConflict object + const mergedConflicts: ConsensusConflict[] = []; + + for (const frame of frames) { + const frameConflicts = conflicts.filter((conflict) => conflict.frame === frame); + const conflictsByObject: Record = {}; + + frameConflicts.forEach((qualityConflict: ConsensusConflict) => { + const { type, serverID } = qualityConflict.annotationConflicts[0]; + const firstObjID = `${type}_${serverID}`; + conflictsByObject[firstObjID] = conflictsByObject[firstObjID] || []; + conflictsByObject[firstObjID].push(qualityConflict); + }); + + for (const objectConflicts of Object.values(conflictsByObject)) { + if (objectConflicts.length === 1) { + // only one quality conflict refers to the object on current frame + mergedConflicts.push(objectConflicts[0]); + } else { + const mainObjectConflict = objectConflicts[0]; + const descriptionList: string[] = [mainObjectConflict.description]; + + for (const objectConflict of objectConflicts) { + if (objectConflict !== mainObjectConflict) { + descriptionList.push(objectConflict.description); + + for (const annotationConflict of objectConflict.annotationConflicts) { + if (!mainObjectConflict.annotationConflicts.find((_annotationConflict) => ( + _annotationConflict.serverID === annotationConflict.serverID && + _annotationConflict.type === annotationConflict.type)) + ) { + mainObjectConflict.annotationConflicts.push(annotationConflict); + } + } + } + } + + // decorate the original conflict to avoid changing it + const description = descriptionList.join(', '); + const visibleConflict = new Proxy(mainObjectConflict, { + get(target, prop) { + if (prop === 'description') { + return description; + } + + // By default, it looks like Reflect.get(target, prop, receiver) + // which has a different value of `this`. It doesn't allow to + // work with methods / properties that use private members. + const val = Reflect.get(target, prop); + return typeof val === 'function' ? (...args: any[]) => val.apply(target, args) : val; + }, + }); + + mergedConflicts.push(visibleConflict); + } + } + } + + return mergedConflicts; + }); implementationMixin(cvat.analytics.performance.calculate, async ( body: Parameters[0], onUpdate: Parameters[1], diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index 639efb73123..3b650efd381 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -402,6 +402,14 @@ function build(): CVATCore { }, }, consensus: { + async reports(filter = {}) { + const result = await PluginRegistry.apiWrapper(cvat.consensus.reports, filter); + return result; + }, + async conflicts(filter = {}) { + const result = await PluginRegistry.apiWrapper(cvat.consensus.conflicts, filter); + return result; + }, settings: { async get(filter = {}) { const result = await PluginRegistry.apiWrapper(cvat.consensus.settings.get, filter); diff --git a/cvat-core/src/index.ts b/cvat-core/src/index.ts index 520c34c8f9c..f1eb9ac4abb 100644 --- a/cvat-core/src/index.ts +++ b/cvat-core/src/index.ts @@ -2,9 +2,9 @@ // // SPDX-License-Identifier: MIT -import ConsensusSettings from 'consensus-settings'; +import ConsensusConflict from 'consensus-conflict'; import { - AnalyticsReportFilter, QualityConflictsFilter, QualityReportsFilter, SettingsFilter, + AnalyticsReportFilter, ConflictsFilter, QualityReportsFilter, SettingsFilter, ConsensusReportsFilter, } from './server-response-types'; import PluginRegistry from './plugins'; import serverProxy from './server-proxy'; @@ -31,6 +31,8 @@ import Webhook from './webhook'; import QualityReport from './quality-report'; import QualityConflict from './quality-conflict'; import QualitySettings from './quality-settings'; +import ConsensusReport from './consensus-report'; +import ConsensusSettings from './consensus-settings'; import AnalyticsReport from './analytics-report'; import AnnotationGuide from './guide'; import { Request } from './request'; @@ -134,6 +136,8 @@ export default interface CVATCore { get: any; }; consensus: { + reports: (filter: ConsensusReportsFilter) => Promise>; + conflicts: (filter: ConflictsFilter) => Promise; settings: { get: (filter: SettingsFilter) => Promise; }; @@ -141,7 +145,7 @@ export default interface CVATCore { analytics: { quality: { reports: (filter: QualityReportsFilter) => Promise>; - conflicts: (filter: QualityConflictsFilter) => Promise; + conflicts: (filter: ConflictsFilter) => Promise; settings: { get: (filter: SettingsFilter) => Promise; }; diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index 409791bb4c0..8ef3e5a812e 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -16,9 +16,10 @@ import { SerializedAbout, SerializedRemoteFile, SerializedUserAgreement, SerializedRegister, JobsFilter, SerializedJob, SerializedGuide, SerializedAsset, SerializedAPISchema, SerializedInvitationData, SerializedCloudStorage, SerializedFramesMetaData, SerializedCollection, - SerializedQualitySettingsData, APISettingsFilter, SerializedQualityConflictData, APIQualityConflictsFilter, + SerializedQualitySettingsData, APISettingsFilter, SerializedQualityConflictData, APIConflictsFilter, SerializedQualityReportData, APIQualityReportsFilter, SerializedAnalyticsReport, APIAnalyticsReportFilter, SerializedConsensusSettingsData, SerializedRequest, + SerializedConsensusConflictData, } from './server-response-types'; import { PaginatedResource } from './core-types'; import { Request } from './request'; @@ -2241,7 +2242,7 @@ async function updateConsensusSettings( } async function getQualityConflicts( - filter: APIQualityConflictsFilter, + filter: APIConflictsFilter, ): Promise { const params = enableOrganization(); const { backendAPI } = config; @@ -2277,6 +2278,43 @@ async function getQualityReports( } } +async function getConsensusConflicts( + filter: APIConflictsFilter, +): Promise { + const params = enableOrganization(); + const { backendAPI } = config; + + try { + const response = await fetchAll(`${backendAPI}/consensus/conflicts`, { + ...params, + ...filter, + }); + + return response.results; + } catch (errorData) { + throw generateError(errorData); + } +} + +async function getConsensusReports( + filter: APIQualityReportsFilter, +): Promise> { + const { backendAPI } = config; + + try { + const response = await Axios.get(`${backendAPI}/consensus/reports`, { + params: { + ...filter, + }, + }); + + response.data.results.count = response.data.count; + return response.data.results; + } catch (errorData) { + throw generateError(errorData); + } +} + async function getAnalyticsReports( filter: APIAnalyticsReportFilter, ): Promise { @@ -2574,6 +2612,8 @@ export default Object.freeze({ }), consensus: Object.freeze({ + reports: getConsensusReports, + conflicts: getConsensusConflicts, settings: Object.freeze({ get: getConsensusSettings, update: updateConsensusSettings, diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index 855eeabfa13..c2950200ac5 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -322,6 +322,62 @@ export interface SerializedQualityReportData { }; } +export interface SerializedAnnotationConsensusConflictData { + job_id?: number; + obj_id?: number; + type?: ObjectType; + shape_type?: string | null; + conflict_type?: string; +} + +export interface SerializedConsensusConflictData { + id?: number; + frame?: number; + type?: string; + annotation_ids?: SerializedAnnotationConsensusConflictData[]; + data?: string; + description?: string; +} + +export interface SerializedConsensusSettingsData { + id?: number; + task?: number; + agreement_score_threshold?: number; + quorum?: number; + iou_threshold?: number; + sigma?: number; +} + +export interface APIConsensusReportsFilter extends APICommonFilterParams { + task_id?: number; + job_id?: number | null; + target?: string; +} + +export type ConsensusReportsFilter = Camelized; + +export interface SerializedConsensusReportData { + id?: number; + task_id?: number; + job_id?: number | null; + created_date?: string; + target?: string; + assignee?: SerializedUser | null; + consensus_score?: number; + summary?: { + frame_count: number; + conflict_count: number; + conflicts_by_type: { + no_matching_item: number; + failed_attribute_voting: number; + no_matching_annotation: number; + annotation_too_close: number; + wrong_group: number; + failed_label_voting: number; + } + }; +} + export interface SerializedDataEntry { date?: string; value?: number | Record diff --git a/cvat-ui/src/cvat-core-wrapper.ts b/cvat-ui/src/cvat-core-wrapper.ts index 67decdba24f..acc6d638ff2 100644 --- a/cvat-ui/src/cvat-core-wrapper.ts +++ b/cvat-ui/src/cvat-core-wrapper.ts @@ -42,6 +42,7 @@ import { Event } from 'cvat-core/src/event'; import { APIWrapperEnterOptions } from 'cvat-core/src/plugins'; import BaseSingleFrameAction, { ActionParameterType, FrameSelectionType } from 'cvat-core/src/annotations-actions'; import { Request } from 'cvat-core/src/request'; +import ConsensusReport from 'cvat-core/src/consensus-report'; const cvat: CVATCore = _cvat; @@ -93,6 +94,7 @@ export { QualityConflict, QualitySettings, ConsensusSettings, + ConsensusReport, AnnotationConflict, ConflictSeverity, FramesMetaData, From 8170c8a3f1786869cbf176613face3baebdff1a0 Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 9 Aug 2024 01:22:58 +0530 Subject: [PATCH 139/301] replace `jobName` change with a Tag --- cvat-ui/src/components/jobs-page/job-card.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cvat-ui/src/components/jobs-page/job-card.tsx b/cvat-ui/src/components/jobs-page/job-card.tsx index 4f4e98e3769..b60bc6d5837 100644 --- a/cvat-ui/src/components/jobs-page/job-card.tsx +++ b/cvat-ui/src/components/jobs-page/job-card.tsx @@ -10,10 +10,11 @@ import Descriptions from 'antd/lib/descriptions'; import { MoreOutlined } from '@ant-design/icons'; import Dropdown from 'antd/lib/dropdown'; -import { Job } from 'cvat-core-wrapper'; +import { Job, JobType } from 'cvat-core-wrapper'; import { useCardHeightHOC } from 'utils/hooks'; import Preview from 'components/common/preview'; import JobActionsMenu from 'components/job-item/job-actions-menu'; +import { Tag } from 'antd'; const useCardHeight = useCardHeightHOC({ containerClassName: 'cvat-jobs-page', @@ -41,6 +42,13 @@ function JobCardComponent(props: Props): JSX.Element { } }; + let tag = null; + if (job.type === JobType.GROUND_TRUTH) { + tag = Ground truth; + } else if (job.type === JobType.CONSENSUS) { + tag = Consensus; + } + return ( {`${job.stage} ${job.state}`} {job.stopFrame - job.startFrame + 1} - { job.assignee ? ( + {job.assignee ? ( {job.assignee.username} - ) : } + ) : ( + + )} + {tag} Date: Fri, 9 Aug 2024 02:34:56 +0530 Subject: [PATCH 140/301] updated `schema.yml` --- cvat/schema.yml | 65 +++++++++++++++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/cvat/schema.yml b/cvat/schema.yml index b746abf7d5d..8506d2087a6 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1130,12 +1130,12 @@ paths: schema: type: string enum: - - NO_MATCHING_ITEM - - FAILED_ATTRIBUTE_VOTING - - NO_MATCHING_ANNOTATION - - ANNOTATION_TOO_CLOSE - - WRONG_GROUP - - FAILED_LABEL_VOTING + - no_matching_item + - failed_attribute_voting + - no_matching_annotation + - annotation_too_close + - wrong_group + - failed_label_voting tags: - consensus security: @@ -1211,6 +1211,11 @@ paths: [''id'', ''job_id'', ''created_date'', ''target_last_updated'']' schema: type: string + - in: query + name: target + schema: + type: string + description: A simple equality filter for target - in: query name: task_id schema: @@ -7680,20 +7685,20 @@ components: - annotation_ids ConsensusConflictTypeEnum: enum: - - NO_MATCHING_ITEM - - FAILED_ATTRIBUTE_VOTING - - NO_MATCHING_ANNOTATION - - ANNOTATION_TOO_CLOSE - - WRONG_GROUP - - FAILED_LABEL_VOTING + - no_matching_item + - failed_attribute_voting + - no_matching_annotation + - annotation_too_close + - wrong_group + - failed_label_voting type: string description: |- - * `NO_MATCHING_ITEM` - NoMatchingItemError - * `FAILED_ATTRIBUTE_VOTING` - FailedAttrVotingError - * `NO_MATCHING_ANNOTATION` - NoMatchingAnnError - * `ANNOTATION_TOO_CLOSE` - AnnotationsTooCloseError - * `WRONG_GROUP` - WrongGroupError - * `FAILED_LABEL_VOTING` - FailedLabelVotingError + * `no_matching_item` - NoMatchingItemError + * `failed_attribute_voting` - FailedAttrVotingError + * `no_matching_annotation` - NoMatchingAnnError + * `annotation_too_close` - AnnotationsTooCloseError + * `wrong_group` - WrongGroupError + * `failed_label_voting` - FailedLabelVotingError ConsensusReport: type: object properties: @@ -7718,8 +7723,19 @@ components: type: string format: date-time readOnly: true + target: + $ref: '#/components/schemas/QualityReportTarget' + assignee: + allOf: + - $ref: '#/components/schemas/BasicUser' + readOnly: true + nullable: true + consensus_score: + type: integer + readOnly: true required: - summary + - target ConsensusReportCreateRequest: type: object properties: @@ -8665,17 +8681,17 @@ components: allOf: - $ref: '#/components/schemas/Storage' nullable: true + assignee_updated_date: + type: string + format: date-time + readOnly: true + nullable: true parent_job_id: type: integer maximum: 2147483647 minimum: 0 nullable: true readOnly: true - assignee_updated_date: - type: string - format: date-time - readOnly: true - nullable: true required: - issues - labels @@ -8754,7 +8770,7 @@ components: properties: count: type: integer - default: 0 + nullable: true completed: type: integer nullable: true @@ -8767,6 +8783,7 @@ components: readOnly: true required: - completed + - count - validation Label: type: object From 924ad450c5ce54094605866f8d40a1270daf323b Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 9 Aug 2024 02:39:47 +0530 Subject: [PATCH 141/301] added tag to consensus job list in task view --- cvat-ui/src/components/job-item/job-item.tsx | 37 ++++++++++++-------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/cvat-ui/src/components/job-item/job-item.tsx b/cvat-ui/src/components/job-item/job-item.tsx index 084adae564a..8662c6253d8 100644 --- a/cvat-ui/src/components/job-item/job-item.tsx +++ b/cvat-ui/src/components/job-item/job-item.tsx @@ -116,7 +116,7 @@ function JobItem(props: Props): JSX.Element { const frameCountPercentRepresentation = frameCountPercent === '0' ? '<1' : frameCountPercent; let jobName = `Job #${job.id}`; if (task.consensusJobsPerRegularJob && job.type !== JobType.GROUND_TRUTH) { - jobName = job.type === JobType.CONSENSUS ? `Consensus Job #${job.id}` : `Regular Job #${job.id}`; + jobName = `Job #${job.id}`; } let consensusJobs: Job[] = []; @@ -126,6 +126,20 @@ function JobItem(props: Props): JSX.Element { const consensusJobViews: React.JSX.Element[] = consensusJobs.map((eachJob: Job) => ( )); + let tag = null; + if (job.type === JobType.GROUND_TRUTH) { + tag = ( + + Ground truth + + ); + } else if (job.type === JobType.CONSENSUS) { + tag = ( + + Consensus + + ); + } return ( @@ -138,19 +152,14 @@ function JobItem(props: Props): JSX.Element { { jobName } - { - job.type === JobType.GROUND_TRUTH ? ( - - Ground truth - - ) : ( - - }> - - - - ) - } + {tag} + {job.type !== JobType.GROUND_TRUTH && ( + + }> + + + + )} From 91caee61ba222dc1652d37e8d3640d27c51f85bb Mon Sep 17 00:00:00 2001 From: vidit Date: Fri, 9 Aug 2024 02:40:46 +0530 Subject: [PATCH 142/301] removed settings reset button --- .../consensus/consensus-settings-form.tsx | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/cvat-ui/src/components/consensus/consensus-settings-form.tsx b/cvat-ui/src/components/consensus/consensus-settings-form.tsx index cee50f847b6..c8891f12276 100644 --- a/cvat-ui/src/components/consensus/consensus-settings-form.tsx +++ b/cvat-ui/src/components/consensus/consensus-settings-form.tsx @@ -14,7 +14,6 @@ import { ConsensusSettings } from 'cvat-core-wrapper'; import { Button } from 'antd/lib'; import notification from 'antd/lib/notification'; import { LoadingOutlined } from '@ant-design/icons'; -import { Modal } from 'antd'; interface Props { settings: ConsensusSettings | null; @@ -147,24 +146,7 @@ export default function ConsensusSettingsForm(props: Props): JSX.Element | null - - - - +