Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ignore deleted frames when converting tracks to shapes #8834

Merged
merged 4 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed

- Fixed handling of tracks keyframes from deleted frames on export
(<https://github.com/cvat-ai/cvat/pull/8834>)
34 changes: 24 additions & 10 deletions cvat/apps/dataset_manager/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import math
from collections.abc import Container, Sequence
from typing import Optional
from typing import Collection, Optional
import numpy as np
from itertools import chain
from scipy.optimize import linear_sum_assignment
Expand Down Expand Up @@ -199,22 +199,27 @@ def clear_frames(self, frames: Container[int]):
# Tracks are not expected in the cases this function is supposed to be used
raise AssertionError("Partial annotation cleanup is not supported for tracks")

def to_shapes(self,
def to_shapes(
self,
end_frame: int,
*,
included_frames: Optional[Sequence[int]] = None,
include_outside: bool = False,
use_server_track_ids: bool = False
use_server_track_ids: bool = False,
deleted_frames: Optional[Collection[int]] = None,
zhiltsov-max marked this conversation as resolved.
Show resolved Hide resolved
) -> list:
shapes = self.data.shapes
tracks = TrackManager(self.data.tracks, dimension=self.dimension)

if included_frames is not None:
shapes = [s for s in shapes if s["frame"] in included_frames]

return shapes + tracks.to_shapes(end_frame,
included_frames=included_frames, include_outside=include_outside,
use_server_track_ids=use_server_track_ids
return shapes + tracks.to_shapes(
end_frame,
included_frames=included_frames,
include_outside=include_outside,
use_server_track_ids=use_server_track_ids,
deleted_frames=deleted_frames,
)

def to_tracks(self):
Expand Down Expand Up @@ -460,14 +465,21 @@ def _unite_objects(obj0, obj1):
def _modify_unmatched_object(self, obj, end_frame):
pass


class TrackManager(ObjectManager):
def to_shapes(self, end_frame: int, *,
def to_shapes(
self,
end_frame: int,
*,
included_frames: Optional[Sequence[int]] = None,
include_outside: bool = False,
use_server_track_ids: bool = False
use_server_track_ids: bool = False,
deleted_frames: Optional[Collection[int]] = None,
) -> list:
shapes = []
for idx, track in enumerate(self.objects):
if deleted_frames is not None:
track = dict(track, shapes=list(filter(lambda sh: sh['frame'] not in deleted_frames, track['shapes'])))
track_id = track["id"] if use_server_track_ids else idx
track_shapes = {}

Expand Down Expand Up @@ -497,10 +509,12 @@ def to_shapes(self, end_frame: int, *,
element_included_frames = set(track_shapes.keys())
if included_frames is not None:
element_included_frames = element_included_frames.intersection(included_frames)
element_shapes = track_elements.to_shapes(end_frame,
element_shapes = track_elements.to_shapes(
end_frame,
included_frames=element_included_frames,
include_outside=True, # elements are controlled by the parent shape
use_server_track_ids=use_server_track_ids
use_server_track_ids=use_server_track_ids,
deleted_frames=deleted_frames,
)

for shape in element_shapes:
Expand Down
10 changes: 6 additions & 4 deletions cvat/apps/dataset_manager/bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ def rel_frame_id(self, absolute_id):
return d

def _init_frame_info(self):
self._deleted_frames = { k: True for k in self._db_data.deleted_frames }
self._deleted_frames = { k for k in self._db_data.deleted_frames }

self._excluded_frames = set()

Expand Down Expand Up @@ -499,7 +499,8 @@ def get_frame(idx):
# Skip outside, deleted and excluded frames
included_frames=included_frames,
include_outside=False,
use_server_track_ids=self._use_server_track_ids
use_server_track_ids=self._use_server_track_ids,
deleted_frames=self.deleted_frames,
),
key=lambda shape: shape.get("z_order", 0)
):
Expand Down Expand Up @@ -695,7 +696,7 @@ def frame_info(self):
return self._frame_info

@property
def deleted_frames(self):
def deleted_frames(self) -> set[int]:
return self._deleted_frames

@property
Expand Down Expand Up @@ -1298,7 +1299,8 @@ def get_frame(task_id: int, idx: int) -> ProjectData.Frame:
task.data.size,
included_frames=task_included_frames,
include_outside=False,
use_server_track_ids=self._use_server_track_ids
use_server_track_ids=self._use_server_track_ids,
deleted_frames=task_data.deleted_frames,
),
key=lambda shape: shape.get("z_order", 0)
):
Expand Down
69 changes: 69 additions & 0 deletions cvat/apps/dataset_manager/tests/test_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
get_paginated_collection, ForceLogin, generate_image_file, ApiTestBase
)


class _DbTestBase(ApiTestBase):
def setUp(self):
super().setUp()
Expand Down Expand Up @@ -87,6 +88,7 @@ def _create_task(self, data, image_data):

return task


class TaskExportTest(_DbTestBase):
def _generate_custom_annotations(self, annotations, task):
self._put_api_v2_task_id_annotations(task["id"], annotations)
Expand Down Expand Up @@ -520,6 +522,72 @@ def test_frames_outside_are_not_generated(self):
self.assertTrue(frame.frame in range(6, 10))
self.assertEqual(i + 1, 4)

def _delete_job_frames(self, job_id: int, deleted_frames: list[int]):
with ForceLogin(self.user, self.client):
response = self.client.patch(
f"/api/jobs/{job_id}/data/meta?org=",
data=dict(deleted_frames=deleted_frames),
format="json"
)
assert response.status_code == status.HTTP_200_OK, response.status_code

def test_track_keyframes_on_deleted_frames_do_not_affect_later_frames(self):
images = self._generate_task_images(4)
task = self._generate_task(images)
job = self._get_task_jobs(task["id"])[0]

annotations = {
"version": 0,
"tags": [],
"shapes": [],
"tracks": [
{
"frame": 0,
"label_id": task["labels"][0]["id"],
"group": None,
"source": "manual",
"attributes": [],
"shapes": [
{
"frame": 0,
"points": [1, 2, 3, 4],
"type": "rectangle",
"occluded": False,
"outside": False,
"attributes": [],
},
{
"frame": 1,
"points": [5, 6, 7, 8],
"type": "rectangle",
"occluded": False,
"outside": True,
"attributes": [],
},
{
"frame": 2,
"points": [9, 10, 11, 12],
"type": "rectangle",
"occluded": False,
"outside": False,
"attributes": [],
},
]
},
]
}
self._put_api_v2_job_id_annotations(job["id"], annotations)
self._delete_job_frames(job["id"], [2])

task_ann = TaskAnnotation(task["id"])
task_ann.init_from_db()
task_data = TaskData(task_ann.ir_data, Task.objects.get(pk=task["id"]))
extractor = CvatTaskOrJobDataExtractor(task_data)
dm_dataset = Dataset.from_extractors(extractor)

assert len(dm_dataset.get("image_3").annotations) == 0


class FrameMatchingTest(_DbTestBase):
def _generate_task_images(self, paths): # pylint: disable=no-self-use
f = BytesIO()
Expand Down Expand Up @@ -612,6 +680,7 @@ def test_dataset_root(self):
root = find_dataset_root(dataset, task_data)
self.assertEqual(expected, root)


class TaskAnnotationsImportTest(_DbTestBase):
def _generate_custom_annotations(self, annotations, task):
self._put_api_v2_task_id_annotations(task["id"], annotations)
Expand Down
Loading