From 77fdc1b652a95e6a5e07e968b18ae10f8b24015f Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:14:58 +0000 Subject: [PATCH 1/5] Fix lost track merge in task annotations with overlapping jobs When the same object was tracked in two overlapping jobs and the track in the later job started earlier in the overlap, the united track returned by TrackManager._unite_objects() was never put back into the task annotations. The old track stayed without its closing outside shape and was propagated to the end of the task in exports, as a static ghost copy of the object. Related to #11204 --- ...120000_fix_task_export_lost_track_merge.md | 6 +++ cvat/apps/dataset_manager/annotation.py | 12 ++++++ .../dataset_manager/tests/test_annotation.py | 43 ++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20260924_120000_fix_task_export_lost_track_merge.md 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..1681917ee694 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,47 @@ 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] + job_tracks = [ + (0, make_track([make_shape(6, dimension=dimension)], frame=6)), + ( + 5, + make_track( + [ + make_shape(5, dimension=dimension), + make_shape(13, outside=True, dimension=dimension), + ], + frame=5, + ), + ), + ] + + task_annotations = AnnotationIR(dimension) + for start_frame, track in job_tracks: + job_annotations = AnnotationIR( + dimension, {"tags": [], "shapes": [], "tracks": [track], "intervals": []} + ) + AnnotationManager(task_annotations, dimension=dimension).merge( + job_annotations, start_frame, overlap=5 + ) + + self.assertEqual(len(task_annotations.tracks), 1) + self.assertEqual( + [(s["frame"], s["outside"]) for s in task_annotations.tracks[0]["shapes"]], + [(5, False), (6, False), (13, True)], + ) + + exported_frames = [ + s["frame"] + for s in AnnotationManager(task_annotations, dimension=dimension).to_shapes(15) + ] + self.assertEqual(exported_frames, [5, 6, 7, 8, 9, 10, 11, 12, 13]) + + class TestTaskAnnotation(TestCase): def test_reads_ordered_jobs(self): user = get_user_model().objects.create_superuser(username="admin", email="", password="") From 1d0f595dbaabded9bfe07423b22ae82c9f1f5373 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:39:45 +0000 Subject: [PATCH 2/5] Check every task track in the track merge test --- cvat/apps/dataset_manager/tests/test_annotation.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cvat/apps/dataset_manager/tests/test_annotation.py b/cvat/apps/dataset_manager/tests/test_annotation.py index 1681917ee694..0543fce9b779 100644 --- a/cvat/apps/dataset_manager/tests/test_annotation.py +++ b/cvat/apps/dataset_manager/tests/test_annotation.py @@ -495,10 +495,12 @@ def test_merge_keeps_track_starting_earlier_in_next_job(self): job_annotations, start_frame, overlap=5 ) - self.assertEqual(len(task_annotations.tracks), 1) self.assertEqual( - [(s["frame"], s["outside"]) for s in task_annotations.tracks[0]["shapes"]], - [(5, False), (6, False), (13, True)], + [ + [(s["frame"], s["outside"]) for s in track["shapes"]] + for track in task_annotations.tracks + ], + [[(5, False), (6, False), (13, True)]], ) exported_frames = [ From 796a7a65fd83783adb60315c698bdb5e4c679fb7 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:42:44 +0000 Subject: [PATCH 3/5] Check which job's track is kept in the track merge test --- cvat/apps/dataset_manager/tests/test_annotation.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cvat/apps/dataset_manager/tests/test_annotation.py b/cvat/apps/dataset_manager/tests/test_annotation.py index 0543fce9b779..7876668746a9 100644 --- a/cvat/apps/dataset_manager/tests/test_annotation.py +++ b/cvat/apps/dataset_manager/tests/test_annotation.py @@ -473,7 +473,7 @@ def test_merge_keeps_track_starting_earlier_in_next_job(self): with self.subTest(dimension=dimension): # job 1 covers frames [0; 9], job 2 covers frames [5; 14] job_tracks = [ - (0, make_track([make_shape(6, dimension=dimension)], frame=6)), + (0, make_track([make_shape(6, dimension=dimension)], frame=6, source="auto")), ( 5, make_track( @@ -497,10 +497,13 @@ def test_merge_keeps_track_starting_earlier_in_next_job(self): self.assertEqual( [ - [(s["frame"], s["outside"]) for s in track["shapes"]] + ( + track["source"], + [(s["frame"], s["outside"]) for s in track["shapes"]], + ) for track in task_annotations.tracks ], - [[(5, False), (6, False), (13, True)]], + [("manual", [(5, False), (6, False), (13, True)])], ) exported_frames = [ From c8e003ed0df65e0a4583e6898b458c09ee97ae5b Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:48:09 +0000 Subject: [PATCH 4/5] Keep an unmatched track in the track merge test --- .../dataset_manager/tests/test_annotation.py | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/cvat/apps/dataset_manager/tests/test_annotation.py b/cvat/apps/dataset_manager/tests/test_annotation.py index 7876668746a9..495fc6ff8263 100644 --- a/cvat/apps/dataset_manager/tests/test_annotation.py +++ b/cvat/apps/dataset_manager/tests/test_annotation.py @@ -472,24 +472,40 @@ 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] - job_tracks = [ - (0, make_track([make_shape(6, dimension=dimension)], frame=6, source="auto")), + 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, - ), + [ + make_track( + [ + make_shape(5, dimension=dimension), + make_shape(13, outside=True, dimension=dimension), + ], + frame=5, + ) + ], ), ] task_annotations = AnnotationIR(dimension) - for start_frame, track in job_tracks: + for start_frame, tracks in jobs: job_annotations = AnnotationIR( - dimension, {"tags": [], "shapes": [], "tracks": [track], "intervals": []} + dimension, {"tags": [], "shapes": [], "tracks": tracks, "intervals": []} ) AnnotationManager(task_annotations, dimension=dimension).merge( job_annotations, start_frame, overlap=5 @@ -503,14 +519,17 @@ def test_merge_keeps_track_starting_earlier_in_next_job(self): ) for track in task_annotations.tracks ], - [("manual", [(5, False), (6, False), (13, True)])], + [ + ("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, [5, 6, 7, 8, 9, 10, 11, 12, 13]) + self.assertEqual(exported_frames, [0, 1, 2, 5, 6, 7, 8, 9, 10, 11, 12, 13]) class TestTaskAnnotation(TestCase): From d8df8e55dc7ac043cceb6659831536905aa363ff Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:03:18 +0000 Subject: [PATCH 5/5] Add reachability and skeleton-element coverage to the track merge test --- .../dataset_manager/tests/test_annotation.py | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/cvat/apps/dataset_manager/tests/test_annotation.py b/cvat/apps/dataset_manager/tests/test_annotation.py index 495fc6ff8263..4e5fe13e314e 100644 --- a/cvat/apps/dataset_manager/tests/test_annotation.py +++ b/cvat/apps/dataset_manager/tests/test_annotation.py @@ -531,6 +531,123 @@ def test_merge_keeps_track_starting_earlier_in_next_job(self): ] 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):