Skip to content

Commit

Permalink
Fix two problems with the task segmentation algorithm (#7681)
Browse files Browse the repository at this point in the history
1. When a task has non-zero overlap and exactly as many frames as needed
   to create 1 or more complete segments, the current algorithm generates a
   redundant segment at the end. For example, if size is 5, segment size is
   3, and overlap is 1, it generates segments (0, 2), (2, 4), and (4, 4).

   The algorithm attempts to compensate for this, but it only works in the
   case where the segment size is unspecified (and defaults to the total
   size).

   Update the algorithm to handle this correctly in the general case.

2. The algorithm selects a default overlap size of 5 if the media file
   is a video. However, this might not be a valid value if the task has a
   very small segment size. In this case, a range of undesirable behaviors
   may occur, depending on the segment size:

   * segments getting generated such that more than 2 segments cover a
     single frame;

   * task creation crashing with an exception;

   * a task being created with no segments at all.

   Fix this by clamping the default overlap size the same way as a
   user-specified one.

Fixes #7675.
  • Loading branch information
SpecLad committed Mar 26, 2024
1 parent 811f6ed commit aec333d
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 13 deletions.
9 changes: 9 additions & 0 deletions changelog.d/20240326_133323_roman_segmentation_fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Fixed

- Fixed an invalid default overlap size being selected for video tasks
with small segments
(<https://github.com/opencv/cvat/pull/7681>)

- Fixed redundant jobs being created for tasks with non-zero overlap
in certain cases
(<https://github.com/opencv/cvat/pull/7681>)
18 changes: 6 additions & 12 deletions cvat/apps/engine/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import itertools
import fnmatch
import os
import sys
from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Union, Iterable
from rest_framework.serializers import ValidationError
import rq
Expand Down Expand Up @@ -132,23 +131,18 @@ def _segments():
data_size = db_task.data.size

segment_size = db_task.segment_size
segment_step = segment_size
if segment_size == 0 or segment_size > data_size:
segment_size = data_size

# Segment step must be more than segment_size + overlap in single-segment tasks
# Otherwise a task contains an extra segment
segment_step = sys.maxsize

overlap = 5 if db_task.mode == 'interpolation' else 0
if db_task.overlap is not None:
overlap = min(db_task.overlap, segment_size // 2)

segment_step -= overlap
overlap = min(
db_task.overlap if db_task.overlap is not None
else 5 if db_task.mode == 'interpolation' else 0,
segment_size // 2,
)

segments = (
SegmentParams(start_frame, min(start_frame + segment_size - 1, data_size - 1))
for start_frame in range(0, data_size, segment_step)
for start_frame in range(0, data_size - overlap, segment_size - overlap)
)

return SegmentsParams(segments, segment_size, overlap)
Expand Down
1 change: 1 addition & 0 deletions tests/python/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
av==12.0.0
pytest==6.2.5
pytest-cases==3.6.13
pytest-timeout==2.1.0
Expand Down
69 changes: 68 additions & 1 deletion tests/python/rest_api/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@
patch_method,
post_method,
)
from shared.utils.helpers import generate_image_file, generate_image_files, generate_manifest
from shared.utils.helpers import (
generate_image_file,
generate_image_files,
generate_manifest,
generate_video_file,
)

from .utils import (
CollectionSimpleFilterTestBase,
Expand Down Expand Up @@ -801,6 +806,68 @@ def test_can_create_task_with_defined_start_and_stop_frames(self):
(task, _) = api_client.tasks_api.retrieve(task_id)
assert task.size == 4

def test_default_overlap_for_small_segment_size(self):
task_spec = {
"name": f"test {self._USERNAME} with default overlap and small segment_size",
"labels": [{"name": "car"}],
"segment_size": 5,
}

task_data = {
"image_quality": 75,
"client_files": [generate_video_file(8)],
}

task_id, _ = create_task(self._USERNAME, task_spec, task_data)

# check task size
with make_api_client(self._USERNAME) as api_client:
paginated_job_list, _ = api_client.jobs_api.list(task_id=task_id)

jobs = paginated_job_list.results
jobs.sort(key=lambda job: job.start_frame)

assert len(jobs) == 2
# overlap should be 2 frames (frames 3 & 4)
assert jobs[0].start_frame == 0
assert jobs[0].stop_frame == 4
assert jobs[1].start_frame == 3
assert jobs[1].stop_frame == 7

@pytest.mark.parametrize(
"size,expected_segments",
[
(2, [(0, 1)]),
(3, [(0, 2)]),
(4, [(0, 2), (2, 3)]),
(5, [(0, 2), (2, 4)]),
(6, [(0, 2), (2, 4), (4, 5)]),
],
)
def test_task_segmentation(self, size, expected_segments):
task_spec = {
"name": f"test {self._USERNAME} to check segmentation into jobs",
"labels": [{"name": "car"}],
"segment_size": 3,
"overlap": 1,
}

task_data = {
"image_quality": 75,
"client_files": generate_image_files(size),
}

task_id, _ = create_task(self._USERNAME, task_spec, task_data)

# check task size
with make_api_client(self._USERNAME) as api_client:
paginated_job_list, _ = api_client.jobs_api.list(task_id=task_id)

jobs = paginated_job_list.results
jobs.sort(key=lambda job: job.start_frame)

assert [(j.start_frame, j.stop_frame) for j in jobs] == expected_segments

def test_can_create_task_with_exif_rotated_images(self):
task_spec = {
"name": f"test {self._USERNAME} to create a task with exif rotated images",
Expand Down
22 changes: 22 additions & 0 deletions tests/python/shared/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from io import BytesIO
from typing import List, Optional

import av
import av.video.reformatter
from PIL import Image

from shared.fixtures.init import get_server_image_tag
Expand Down Expand Up @@ -38,6 +40,26 @@ def generate_image_files(
return images


def generate_video_file(num_frames: int, size=(50, 50)) -> BytesIO:
f = BytesIO()
f.name = "video.avi"

with av.open(f, "w") as container:
stream = container.add_stream("mjpeg", rate=60)
stream.width = size[0]
stream.height = size[1]
stream.color_range = av.video.reformatter.ColorRange.JPEG

for i in range(num_frames):
frame = av.VideoFrame.from_image(Image.new("RGB", size=size, color=(i, i, i)))
for packet in stream.encode(frame):
container.mux(packet)

f.seek(0)

return f


def generate_manifest(path: str) -> None:
command = [
"docker",
Expand Down

0 comments on commit aec333d

Please sign in to comment.