From c5d8a16c880017993557d86f203630f6eadfcd3c Mon Sep 17 00:00:00 2001 From: Yeek Sheng <104289235+Yeek020407@users.noreply.github.com> Date: Fri, 19 Apr 2024 20:10:45 +0800 Subject: [PATCH] [GSoC2024] Fix import of outside track shapes in Datumaro-based formats (#7669) ### Motivation and context Main Issue: #7571 Related Issue: #2339 I've reproduced the issue mentioned in #7571 when exporting and importing annotations using both the Datumaro and Coco 1.0 formats. Specifically, the "Switch outside" attribute isn't being applied as expected. After some investigation, I pinpointed the root cause to be the absence of the "outside" attribute in the exported annotations. To address this, I've made adjustments to the binding.py file to bypass the track_id during annotation import. This modification appears to solve the issue regarding the "Switch outside" attribute. However, it introduces a new concern: the potential loss of information, including keyframes and track_id. While this workaround offers a temporary fix, I'm contemplating a more holistic approach that entails properly handling the "outside" attribute during both the export and import processes of annotations. This method could preserve the integrity of the annotations while ensuring the functionality of the "Switch outside" attribute. I'm reaching out for feedback or suggestions on my proposed solution. Is there a preference for one of these approaches, or might there be another avenue I haven't considered? Looking forward to your insights. ### Checklist - [x] I submit my changes into the `develop` branch - [x] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [x] I have added tests to cover my changes - [x] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/opencv/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/opencv/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/opencv/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/opencv/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/opencv/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. --------- Co-authored-by: Maxim Zhiltsov --- ...240403_135916_yeek020407_switch_outside.md | 4 + cvat/apps/dataset_manager/bindings.py | 68 +- tests/python/rest_api/test_tasks.py | 897 +++++++++++++++++- 3 files changed, 928 insertions(+), 41 deletions(-) create mode 100644 changelog.d/20240403_135916_yeek020407_switch_outside.md diff --git a/changelog.d/20240403_135916_yeek020407_switch_outside.md b/changelog.d/20240403_135916_yeek020407_switch_outside.md new file mode 100644 index 000000000000..3e6b98a57421 --- /dev/null +++ b/changelog.d/20240403_135916_yeek020407_switch_outside.md @@ -0,0 +1,4 @@ +### Fixed + +- Formats with the custom `track_id` attribute should import `outside` track shapes properly (e.g. `COCO`, `COCO Keypoints`, `Datumaro`, `PASCAL VOC`) + () diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 2790f7947d25..083c0334f59a 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -15,6 +15,7 @@ from typing import (Any, Callable, DefaultDict, Dict, Iterable, List, Literal, Mapping, NamedTuple, Optional, OrderedDict, Sequence, Set, Tuple, Union) +from attrs.converters import to_bool import datumaro as dm import defusedxml.ElementTree as ET import numpy as np @@ -1953,7 +1954,8 @@ def import_dm_annotations(dm_dataset: dm.Dataset, instance_data: Union[ProjectDa 'sly_pointcloud', 'coco', 'coco_instances', - 'coco_person_keypoints' + 'coco_person_keypoints', + 'voc' ] label_cat = dm_dataset.categories()[dm.AnnotationType.label] @@ -2031,9 +2033,9 @@ def reduce_fn(acc, v): # because in some formats return type can be different # from bool / None # https://github.com/openvinotoolkit/datumaro/issues/719 - occluded = dm.util.cast(ann.attributes.pop('occluded', None), bool) is True - keyframe = dm.util.cast(ann.attributes.get('keyframe', None), bool) is True - outside = dm.util.cast(ann.attributes.pop('outside', None), bool) is True + occluded = dm.util.cast(ann.attributes.pop('occluded', None), to_bool) is True + keyframe = dm.util.cast(ann.attributes.get('keyframe', None), to_bool) is True + outside = dm.util.cast(ann.attributes.pop('outside', None), to_bool) is True track_id = ann.attributes.pop('track_id', None) source = ann.attributes.pop('source').lower() \ @@ -2080,7 +2082,7 @@ def reduce_fn(acc, v): )) continue - if keyframe or outside: + if dm_dataset.format in track_formats: if track_id not in tracks: tracks[track_id] = { 'label': label_cat.items[ann.label].name, @@ -2107,11 +2109,8 @@ def reduce_fn(acc, v): if ann.type == dm.AnnotationType.skeleton: for element in ann.elements: - element_keyframe = dm.util.cast(element.attributes.get('keyframe', None), bool, True) element_occluded = element.visibility[0] == dm.Points.Visibility.hidden element_outside = element.visibility[0] == dm.Points.Visibility.absent - if not element_keyframe and not element_outside: - continue if element.label not in tracks[track_id]['elements']: tracks[track_id]['elements'][element.label] = instance_data.Track( @@ -2120,6 +2119,7 @@ def reduce_fn(acc, v): source=source, shapes=[], ) + element_attributes = [ instance_data.Attribute(name=n, value=str(v)) for n, v in element.attributes.items() @@ -2151,10 +2151,54 @@ def reduce_fn(acc, v): raise CvatImportError("Image {}: can't import annotation " "#{} ({}): {}".format(item.id, idx, ann.type.name, e)) from e - for track in tracks.values(): - track['elements'] = list(track['elements'].values()) - instance_data.add_track(instance_data.Track(**track)) - + def _validate_track_shapes(shapes): + shapes = sorted(shapes, key=lambda t: t.frame) + new_shapes = [] + prev_shape = None + # infer the keyframe shapes and keep only them + for shape in shapes: + prev_is_visible = prev_shape and not prev_shape.outside + cur_is_visible = shape and not shape.outside + + has_gap = False + if prev_is_visible: + has_gap = prev_shape.frame + instance_data.frame_step < shape.frame + + if has_gap: + prev_shape = prev_shape._replace(outside=True, keyframe=True, + frame=prev_shape.frame + instance_data.frame_step) + new_shapes.append(prev_shape) + + if prev_is_visible != cur_is_visible or cur_is_visible and (has_gap or shape.keyframe): + shape = shape._replace(keyframe=True) + new_shapes.append(shape) + + prev_shape = shape + + if prev_shape and not prev_shape.outside and ( + prev_shape.frame + instance_data.frame_step <= stop_frame + # has a gap before the current instance segment end + ): + prev_shape = prev_shape._replace(outside=True, keyframe=True, + frame=prev_shape.frame + instance_data.frame_step) + new_shapes.append(prev_shape) + + return new_shapes + + stop_frame = int(instance_data.meta[instance_data.META_FIELD]['stop_frame']) + for track_id, track in tracks.items(): + track['shapes'] = _validate_track_shapes(track['shapes']) + + if ann.type == dm.AnnotationType.skeleton: + new_elements = {} + for element_id, element in track['elements'].items(): + new_element_shapes = _validate_track_shapes(element.shapes) + new_elements[element_id] = element._replace(shapes=new_element_shapes) + track['elements'] = new_elements + + if track['shapes'] or track['elements']: + track['elements'] = list(track['elements'].values()) + instance_data.add_track(instance_data.Track(**track)) def import_labels_to_project(project_annotation, dataset: dm.Dataset): labels = [] diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index f65415eb447d..bbbcc44b7d01 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -39,6 +39,7 @@ make_api_client, patch_method, post_method, + put_method, ) from shared.utils.helpers import ( generate_image_file, @@ -2819,42 +2820,880 @@ def test_import_annotations(self, task_kind, annotation_kind, expect_success): assert b"Could not match item id" in capture.value.body - def test_can_export_and_import_skeleton_tracks_in_coco_format(self): - task = self.client.tasks.retrieve(14) - dataset_file = self.tmp_dir / "some_file.zip" - format_name = "COCO Keypoints 1.0" + def delete_annotation_and_import_annotations( + self, task_id, annotations, format_name, dataset_file + ): + task = self.client.tasks.retrieve(task_id) + labels = task.get_labels() + sublabels = labels[0].sublabels - original_annotations = task.get_annotations() + # if the annotations shapes label_id does not exist, the put it in the task + for shape in annotations["shapes"]: + if "label_id" not in shape: + shape["label_id"] = labels[0].id + + for track in annotations["tracks"]: + if "label_id" not in track: + track["label_id"] = labels[0].id + for element_idx, element in enumerate(track["elements"]): + if "label_id" not in element: + element["label_id"] = sublabels[element_idx].id + + response = put_method( + "admin1", f"tasks/{task_id}/annotations", annotations, action="create" + ) + assert response.status_code == 200, f"Cannot update task's annotations: {response.content}" task.export_dataset(format_name, dataset_file, include_images=False) - task.remove_annotations() + + # get the original annotations + response = get_method("admin1", f"tasks/{task.id}/annotations") + assert response.status_code == 200, f"Cannot get task's annotations: {response.content}" + original_annotations = response.json() + + # import the annotations task.import_annotations(format_name, dataset_file) - imported_annotations = task.get_annotations() + response = get_method("admin1", f"tasks/{task.id}/annotations") + assert response.status_code == 200, f"Cannot get task's annotations: {response.content}" + imported_annotations = response.json() + + return original_annotations, imported_annotations + + def compare_original_and_import_annotations(self, original_annotations, imported_annotations): + assert ( + DeepDiff( + original_annotations, + imported_annotations, + ignore_order=True, + exclude_regex_paths=[ + r"root(\['\w+'\]\[\d+\])+\['id'\]", + r"root(\['\w+'\]\[\d+\])+\['label_id'\]", + r"root(\['\w+'\]\[\d+\])+\['attributes'\]\[\d+\]\['spec_id'\]", + ], + ) + == {} + ) - # Number of shapes and tracks hasn't changed - assert len(original_annotations.shapes) == len(imported_annotations.shapes) - assert len(original_annotations.tracks) == len(imported_annotations.tracks) + @pytest.mark.parametrize("format_name", ["Datumaro 1.0", "COCO 1.0", "PASCAL VOC 1.1"]) + def test_export_and_import_tracked_format_with_outside_true(self, format_name): + task_id = 14 + dataset_file = self.tmp_dir / (format_name + "outside_true_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "rectangle", + "frame": 0, + "points": [1.0, 2.0, 3.0, 2.0], + "keyframe": True, + }, + { + "type": "rectangle", + "frame": 3, + "points": [1.0, 2.0, 3.0, 2.0], + "keyframe": True, + "outside": True, + }, + ], + "elements": [], + } + ], + } - # Frames of shapes, tracks and track elements hasn't changed - assert set([s.frame for s in original_annotations.shapes]) == set( - [s.frame for s in imported_annotations.shapes] + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file ) - assert set([t.frame for t in original_annotations.tracks]) == set( - [t.frame for t in imported_annotations.tracks] + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # check if frame 3 is imported correctly with outside = True + assert imported_annotations["tracks"][0]["shapes"][1]["outside"] + + @pytest.mark.parametrize("format_name", ["Datumaro 1.0", "COCO 1.0", "PASCAL VOC 1.1"]) + def test_export_and_import_tracked_format_with_intermediate_keyframe(self, format_name): + task_id = 14 + dataset_file = self.tmp_dir / (format_name + "intermediate_keyframe_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "rectangle", + "frame": 0, + "points": [1.0, 2.0, 3.0, 2.0], + "keyframe": True, + }, + { + "type": "rectangle", + "frame": 3, + "points": [1.0, 2.0, 3.0, 2.0], + "keyframe": True, + }, + ], + "elements": [], + } + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file ) - assert set( - [ - tes.frame - for t in original_annotations.tracks - for te in t.elements - for tes in te.shapes - ] - ) == set( - [ - tes.frame - for t in imported_annotations.tracks - for te in t.elements - for tes in te.shapes - ] + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # check that all the keyframe is imported correctly + assert len(imported_annotations["tracks"][0]["shapes"]) == 2 + + @pytest.mark.parametrize("format_name", ["Datumaro 1.0", "COCO 1.0", "PASCAL VOC 1.1"]) + def test_export_and_import_tracked_format_with_outside_without_keyframe(self, format_name): + task_id = 14 + dataset_file = self.tmp_dir / (format_name + "outside_without_keyframe_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "rectangle", + "frame": 0, + "points": [1.0, 2.0, 3.0, 2.0], + "keyframe": True, + }, + { + "type": "rectangle", + "frame": 3, + "points": [1.0, 2.0, 3.0, 2.0], + "outside": True, + }, + ], + "elements": [], + } + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file + ) + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # check that all the keyframe is imported correctly + assert len(imported_annotations["tracks"][0]["shapes"]) == 2 + + # check that frame 3 is imported correctly with outside = True + assert imported_annotations["tracks"][0]["shapes"][1]["outside"] + + @pytest.mark.parametrize("format_name", ["Datumaro 1.0", "COCO 1.0", "PASCAL VOC 1.1"]) + def test_export_and_import_tracked_format_with_no_keyframe(self, format_name): + task_id = 14 + dataset_file = self.tmp_dir / (format_name + "no_keyframe_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "rectangle", + "frame": 0, + "points": [1.0, 2.0, 3.0, 2.0], + }, + ], + "elements": [], + } + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file + ) + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # check if first frame is imported correctly with keyframe = True + assert len(imported_annotations["tracks"][0]["shapes"]) == 1 + + @pytest.mark.parametrize("format_name", ["Datumaro 1.0", "COCO 1.0", "PASCAL VOC 1.1"]) + def test_export_and_import_tracked_format_with_one_outside(self, format_name): + task_id = 14 + dataset_file = self.tmp_dir / (format_name + "one_outside_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "rectangle", + "frame": 3, + "points": [1.0, 2.0, 3.0, 2.0], + "outside": True, + }, + ], + "elements": [], + } + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file + ) + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # only outside=True shape is imported, means there is no visible shape + assert len(imported_annotations["tracks"]) == 0 + + @pytest.mark.parametrize("format_name", ["Datumaro 1.0", "COCO 1.0", "PASCAL VOC 1.1"]) + def test_export_and_import_tracked_format_with_gap(self, format_name): + task_id = 14 + dataset_file = self.tmp_dir / (format_name + "with_gap_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "rectangle", + "frame": 0, + "points": [1.0, 2.0, 3.0, 2.0], + "keyframe": True, + }, + { + "type": "rectangle", + "frame": 2, + "points": [1.0, 2.0, 3.0, 2.0], + "outside": True, + }, + { + "type": "rectangle", + "frame": 4, + "points": [1.0, 2.0, 3.0, 2.0], + "keyframe": True, + }, + { + "type": "rectangle", + "frame": 5, + "points": [1.0, 2.0, 3.0, 2.0], + "outside": True, + }, + { + "type": "rectangle", + "frame": 6, + "points": [1.0, 2.0, 3.0, 2.0], + "keyframe": True, + }, + ], + "elements": [], + } + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file + ) + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # check that all the keyframe is imported correctly + assert len(imported_annotations["tracks"][0]["shapes"]) == 5 + + outside_count = sum( + 1 for shape in imported_annotations["tracks"][0]["shapes"] if shape["outside"] + ) + assert outside_count == 2, "Outside shapes are not imported correctly" + + def test_export_and_import_coco_keypoints_with_outside_true(self): + task_id = 14 + format_name = "COCO Keypoints 1.0" + dataset_file = self.tmp_dir / (format_name + "outside_true_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + {"type": "skeleton", "frame": 0, "points": [], "keyframe": True}, + { + "type": "skeleton", + "frame": 3, + "points": [], + "keyframe": True, + "outside": True, + }, + ], + "elements": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "frame": 0, + "points": [1.0, 2.0], + "keyframe": True, + }, + { + "type": "points", + "frame": 3, + "points": [1.0, 2.0], + "keyframe": True, + "outside": True, + }, + ], + }, + ], + } + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file + ) + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # check if frame 3 is imported correctly with outside = True + assert imported_annotations["tracks"][0]["shapes"][1]["outside"] + + def test_export_and_import_coco_keypoints_with_intermediate_keyframe(self): + task_id = 14 + format_name = "COCO Keypoints 1.0" + dataset_file = self.tmp_dir / (format_name + "intermediate_keyframe_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + {"type": "skeleton", "frame": 0, "points": [], "keyframe": True}, + { + "type": "skeleton", + "frame": 3, + "points": [], + "keyframe": True, + }, + ], + "elements": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "frame": 0, + "points": [1.0, 2.0], + "keyframe": True, + }, + { + "type": "points", + "frame": 3, + "points": [1.0, 2.0], + "keyframe": True, + }, + ], + }, + ], + } + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file ) + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # check that all the keyframe is imported correctly + assert len(imported_annotations["tracks"][0]["shapes"]) == 2 + + def test_export_and_import_coco_keypoints_with_outside_without_keyframe(self): + task_id = 14 + format_name = "COCO Keypoints 1.0" + dataset_file = self.tmp_dir / (format_name + "outside_without_keyframe_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + {"type": "skeleton", "frame": 0, "points": [], "keyframe": True}, + { + "type": "skeleton", + "frame": 3, + "points": [], + "outside": True, + }, + ], + "elements": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "frame": 0, + "points": [1.0, 2.0], + "keyframe": True, + }, + { + "type": "points", + "frame": 3, + "points": [1.0, 2.0], + "outside": True, + }, + ], + }, + ], + } + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file + ) + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # check that all the keyframe is imported correctly + assert len(imported_annotations["tracks"][0]["shapes"]) == 2 + + # check that frame 3 is imported correctly with outside = True + assert imported_annotations["tracks"][0]["shapes"][1]["outside"] + + def test_export_and_import_coco_keypoints_with_no_keyframe(self): + task_id = 14 + format_name = "COCO Keypoints 1.0" + dataset_file = self.tmp_dir / (format_name + "with_no_keyframe_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + {"type": "skeleton", "frame": 0, "points": []}, + ], + "elements": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "frame": 0, + "points": [1.0, 2.0], + }, + ], + }, + ], + } + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file + ) + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # check if first frame is imported correctly with keyframe = True + assert len(imported_annotations["tracks"][0]["shapes"]) == 1 + + def test_export_and_import_coco_keypoints_with_one_outside(self): + task_id = 14 + format_name = "COCO Keypoints 1.0" + dataset_file = self.tmp_dir / (format_name + "with_one_outside_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + {"type": "skeleton", "frame": 3, "points": [], "outside": True}, + ], + "elements": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "frame": 3, + "points": [1.0, 2.0], + "outside": True, + }, + ], + }, + ], + } + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file + ) + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # only outside=True shape is imported, means there is no visible shape + assert len(imported_annotations["tracks"]) == 0 + + def test_export_and_import_coco_keypoints_with_gap(self): + task_id = 14 + format_name = "COCO Keypoints 1.0" + dataset_file = self.tmp_dir / (format_name + "with_gap_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + {"type": "skeleton", "frame": 0, "points": [], "keyframe": True}, + {"type": "skeleton", "frame": 2, "points": [], "outside": True}, + {"type": "skeleton", "frame": 4, "points": [], "keyframe": True}, + {"type": "skeleton", "frame": 5, "points": [], "outside": True}, + {"type": "skeleton", "frame": 6, "points": [], "keyframe": True}, + ], + "elements": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "frame": 0, + "points": [1.0, 2.0], + "keyframe": True, + }, + { + "type": "points", + "frame": 2, + "points": [1.0, 2.0], + "outside": True, + }, + { + "type": "points", + "frame": 4, + "points": [1.0, 2.0], + "keyframe": True, + }, + { + "type": "points", + "frame": 5, + "points": [1.0, 2.0], + "outside": True, + }, + { + "type": "points", + "frame": 6, + "points": [1.0, 2.0], + "keyframe": True, + }, + ], + }, + ], + } + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file + ) + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + # check if all the keyframes are imported correctly + assert len(imported_annotations["tracks"][0]["shapes"]) == 5 + + outside_count = sum( + 1 for shape in imported_annotations["tracks"][0]["shapes"] if shape["outside"] + ) + assert outside_count == 2, "Outside shapes are not imported correctly" + + def test_export_and_import_complex_coco_keypoints_annotations(self): + task_id = 14 + format_name = "COCO Keypoints 1.0" + dataset_file = self.tmp_dir / (format_name + "complex_annotations_source_data.zip") + annotations = { + "shapes": [], + "tracks": [ + { + "frame": 0, + "group": 0, + "shapes": [ + {"type": "skeleton", "outside": False, "frame": 0}, + {"type": "skeleton", "outside": False, "frame": 1}, + {"type": "skeleton", "outside": False, "frame": 2}, + {"type": "skeleton", "outside": False, "frame": 4}, + {"type": "skeleton", "outside": False, "frame": 5}, + ], + "attributes": [], + "elements": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "outside": False, + "points": [256.67, 719.25], + "frame": 0, + }, + { + "type": "points", + "outside": False, + "points": [256.67, 719.25], + "frame": 1, + }, + { + "type": "points", + "outside": True, + "points": [256.67, 719.25], + "frame": 2, + }, + { + "type": "points", + "outside": False, + "points": [256.67, 719.25], + "frame": 4, + }, + { + "type": "points", + "outside": False, + "points": [256.67, 719.25], + "frame": 5, + }, + ], + }, + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "outside": False, + "points": [318.25, 842.06], + "frame": 0, + }, + { + "type": "points", + "outside": True, + "points": [318.25, 842.06], + "frame": 1, + }, + { + "type": "points", + "outside": False, + "points": [318.25, 842.06], + "frame": 2, + }, + { + "type": "points", + "outside": True, + "points": [318.25, 842.06], + "frame": 4, + }, + ], + }, + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "outside": False, + "points": [199.2, 798.71], + "frame": 0, + }, + { + "type": "points", + "outside": False, + "points": [199.2, 798.71], + "frame": 1, + }, + { + "type": "points", + "outside": True, + "points": [199.2, 798.71], + "frame": 2, + }, + { + "type": "points", + "outside": False, + "points": [199.2, 798.71], + "frame": 4, + }, + { + "type": "points", + "outside": True, + "points": [199.2, 798.71], + "frame": 5, + }, + ], + }, + ], + }, + { + "frame": 0, + "group": 0, + "shapes": [ + {"type": "skeleton", "outside": False, "frame": 0}, + {"type": "skeleton", "outside": True, "frame": 1}, + {"type": "skeleton", "outside": False, "frame": 3}, + {"type": "skeleton", "outside": False, "frame": 4}, + {"type": "skeleton", "outside": False, "frame": 5}, + ], + "attributes": [], + "elements": [ + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "outside": False, + "points": [416.16, 244.31], + "frame": 0, + }, + { + "type": "points", + "outside": True, + "points": [416.16, 244.31], + "frame": 1, + }, + { + "type": "points", + "outside": False, + "points": [416.16, 244.31], + "frame": 3, + }, + { + "type": "points", + "outside": False, + "points": [416.16, 244.31], + "frame": 4, + }, + { + "type": "points", + "outside": False, + "points": [416.16, 244.31], + "frame": 5, + }, + ], + }, + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "outside": False, + "points": [486.17, 379.65], + "frame": 0, + }, + { + "type": "points", + "outside": True, + "points": [486.17, 379.65], + "frame": 1, + }, + { + "type": "points", + "outside": True, + "points": [486.17, 379.65], + "frame": 3, + }, + { + "type": "points", + "outside": False, + "points": [486.17, 379.65], + "frame": 4, + }, + { + "type": "points", + "outside": False, + "points": [486.17, 379.65], + "frame": 5, + }, + ], + }, + { + "frame": 0, + "group": 0, + "shapes": [ + { + "type": "points", + "outside": False, + "points": [350.83, 331.88], + "frame": 0, + }, + { + "type": "points", + "outside": True, + "points": [350.83, 331.88], + "frame": 1, + }, + { + "type": "points", + "outside": True, + "points": [350.83, 331.88], + "frame": 3, + }, + { + "type": "points", + "outside": False, + "points": [350.83, 331.88], + "frame": 5, + }, + ], + }, + ], + }, + ], + } + + original_annotations, imported_annotations = self.delete_annotation_and_import_annotations( + task_id, annotations, format_name, dataset_file + ) + + self.compare_original_and_import_annotations(original_annotations, imported_annotations) + + def check_element_outside_count(track_idx, element_idx, expected_count): + outside_count = sum( + 1 + for shape in imported_annotations["tracks"][0]["elements"][element_idx]["shapes"] + if shape["outside"] + ) + assert ( + outside_count == expected_count + ), f"Outside shapes for track[{track_idx}]element[{element_idx}] are not imported correctly" + + # check track[0] elements outside count + check_element_outside_count(0, 0, 1) + check_element_outside_count(0, 1, 2) + check_element_outside_count(0, 2, 2) + + # check track[1] elements outside count + check_element_outside_count(1, 0, 1) + check_element_outside_count(1, 1, 2) + check_element_outside_count(1, 2, 2)