diff --git a/changelog.d/20260924_120000_fix_task_export_lost_track_merge.md b/changelog.d/20260924_120000_fix_task_export_lost_track_merge.md new file mode 100644 index 000000000000..99e2411b312e --- /dev/null +++ b/changelog.d/20260924_120000_fix_task_export_lost_track_merge.md @@ -0,0 +1,6 @@ +### Fixed + +- Task and project exports could contain extra static copies of tracks + continuing to the end of the task when the same object was tracked in two + overlapping jobs and the track in the later job started earlier in the overlap + () diff --git a/cvat/apps/dataset_manager/annotation.py b/cvat/apps/dataset_manager/annotation.py index 734568108bfb..57ce27186d93 100644 --- a/cvat/apps/dataset_manager/annotation.py +++ b/cvat/apps/dataset_manager/annotation.py @@ -452,9 +452,21 @@ def merge(self, objects, start_frame, overlap): if frame in old_objects_by_frame: int_objects = int_objects_by_frame[frame] old_objects = old_objects_by_frame[frame] + matched_old_objects = list(old_objects) new_objects = self._merge_objects_on_one_frame( int_objects, old_objects, start_frame, overlap ) + + # The united object can differ from the old one it was matched with + # (e.g. a track that starts earlier in the next job), so replace it. + united_objects = { + id(old_obj): obj + for old_obj, obj in zip(matched_old_objects, old_objects) + if obj is not old_obj + } + if united_objects: + self.objects[:] = [united_objects.get(id(obj), obj) for obj in self.objects] + self.objects.extend(new_objects) else: # We don't have old objects on the frame. Let's add all new ones. diff --git a/cvat/apps/dataset_manager/tests/test_annotation.py b/cvat/apps/dataset_manager/tests/test_annotation.py index 010c9a882b92..4e5fe13e314e 100644 --- a/cvat/apps/dataset_manager/tests/test_annotation.py +++ b/cvat/apps/dataset_manager/tests/test_annotation.py @@ -14,7 +14,7 @@ from django.test import TestCase from cvat.apps.dataset_manager import task as task_module -from cvat.apps.dataset_manager.annotation import AnnotationIR, TrackManager +from cvat.apps.dataset_manager.annotation import AnnotationIR, AnnotationManager, TrackManager from cvat.apps.engine import models from cvat.apps.engine.models import DimensionType, JobType, ShapeType from cvat.apps.engine.tests.utils import compare_objects @@ -467,6 +467,188 @@ def test_slice_track_does_not_duplicate_outside_frame_on_the_end(self): self.assertEqual(sliced_annotation.data["tracks"][0]["shapes"], shapes[0:2]) +class AnnotationManagerTest(TestCase): + def test_merge_keeps_track_starting_earlier_in_next_job(self): + for dimension in [DimensionType.DIM_2D, DimensionType.DIM_3D]: + with self.subTest(dimension=dimension): + # job 1 covers frames [0; 9], job 2 covers frames [5; 14] + jobs = [ + ( + 0, + [ + make_track( + [make_shape(6, dimension=dimension)], frame=6, source="auto" + ), + make_track( + [ + make_shape(0, base=50, dimension=dimension), + make_shape(2, base=50, outside=True, dimension=dimension), + ], + source="semi-auto", + ), + ], + ), + ( + 5, + [ + make_track( + [ + make_shape(5, dimension=dimension), + make_shape(13, outside=True, dimension=dimension), + ], + frame=5, + ) + ], + ), + ] + + task_annotations = AnnotationIR(dimension) + for start_frame, tracks in jobs: + job_annotations = AnnotationIR( + dimension, {"tags": [], "shapes": [], "tracks": tracks, "intervals": []} + ) + AnnotationManager(task_annotations, dimension=dimension).merge( + job_annotations, start_frame, overlap=5 + ) + + self.assertEqual( + [ + ( + track["source"], + [(s["frame"], s["outside"]) for s in track["shapes"]], + ) + for track in task_annotations.tracks + ], + [ + ("manual", [(5, False), (6, False), (13, True)]), + ("semi-auto", [(0, False), (2, True)]), + ], + ) + + exported_frames = [ + s["frame"] + for s in AnnotationManager(task_annotations, dimension=dimension).to_shapes(15) + ] + self.assertEqual(exported_frames, [0, 1, 2, 5, 6, 7, 8, 9, 10, 11, 12, 13]) + + def test_merge_keeps_replaced_track_reachable(self): + # Each case merges jobs the same way as + # test_merge_keeps_track_starting_earlier_in_next_job, then checks a + # different consequence of replacing the matched track in self.objects. + cases = { + "two tracks matched in the same merge are both replaced": ( + [ + ( + 0, + [ + make_track([make_shape(6, base=10)], frame=6, source="auto"), + make_track([make_shape(6, base=20)], frame=6, source="auto"), + ], + ), + ( + 5, + [ + make_track( + [make_shape(5, base=10), make_shape(13, base=10, outside=True)], + frame=5, + ), + make_track( + [make_shape(5, base=20), make_shape(13, base=20, outside=True)], + frame=5, + ), + ], + ), + ], + [ + ("manual", [(5, False), (6, False), (13, True)]), + ("manual", [(5, False), (6, False), (13, True)]), + ], + ), + "a track replaced by job 2 is united again by job 3": ( + [ + (0, [make_track([make_shape(6, base=30)], frame=6, source="auto")]), + (5, [make_track([make_shape(5, base=30), make_shape(12, base=30)], frame=5)]), + ( + 10, + [ + make_track( + [make_shape(11, base=30), make_shape(16, base=30, outside=True)], + frame=11, + ) + ], + ), + ], + [("manual", [(5, False), (6, False), (11, False), (12, False), (16, True)])], + ), + } + for name, (jobs, expected) in cases.items(): + with self.subTest(name=name): + task_annotations = AnnotationIR(DimensionType.DIM_2D) + for start_frame, tracks in jobs: + job_annotations = AnnotationIR( + DimensionType.DIM_2D, + {"tags": [], "shapes": [], "tracks": tracks, "intervals": []}, + ) + AnnotationManager(task_annotations, dimension=DimensionType.DIM_2D).merge( + job_annotations, start_frame, overlap=5 + ) + self.assertEqual( + [ + ( + track["source"], + [(s["frame"], s["outside"]) for s in track["shapes"]], + ) + for track in task_annotations.tracks + ], + expected, + ) + + def test_merge_keeps_track_starting_earlier_in_next_job_with_elements(self): + def skeleton(shapes, *, frame, source="manual"): + return { + "frame": frame, + "label_id": 0, + "group": None, + "source": source, + "attributes": [], + "shapes": shapes, + "elements": [ + { + "frame": frame, + "label_id": 0, + "group": None, + "source": source, + "attributes": [], + "shapes": [dict(s) for s in shapes], + "elements": [], + } + ], + } + + job1 = [skeleton([make_shape(6)], frame=6, source="auto")] + job2 = [skeleton([make_shape(5), make_shape(13, outside=True)], frame=5)] + + task_annotations = AnnotationIR(DimensionType.DIM_2D) + for start_frame, tracks in [(0, job1), (5, job2)]: + job_annotations = AnnotationIR( + DimensionType.DIM_2D, + {"tags": [], "shapes": [], "tracks": tracks, "intervals": []}, + ) + AnnotationManager(task_annotations, dimension=DimensionType.DIM_2D).merge( + job_annotations, start_frame, overlap=5 + ) + + track = task_annotations.tracks[0] + self.assertEqual( + [(s["frame"], s["outside"]) for s in track["shapes"]], + [(5, False), (6, False), (13, True)], + ) + self.assertEqual( + [(s["frame"], s["outside"]) for s in track["elements"][0]["shapes"]], + [(5, False), (13, True)], + ) + + class TestTaskAnnotation(TestCase): def test_reads_ordered_jobs(self): user = get_user_model().objects.create_superuser(username="admin", email="", password="")