Files
cvat-ai--cvat/tests/python/rest_api/test_audio_support.py
T
2026-07-13 13:32:23 +08:00

396 lines
14 KiB
Python

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import math
from collections.abc import Generator
from itertools import product
from pathlib import Path, PurePosixPath
import pytest
from cvat_sdk import exceptions, models
from cvat_sdk.core.exceptions import BackgroundRequestException
from cvat_sdk.core.proxies.tasks import ResourceType, Task
from PIL import Image
from pytest_cases import fixture, fixture_ref, parametrize
import shared.utils.s3 as s3
from shared.utils.config import (
SHARE_DIR,
make_sdk_client,
)
from shared.utils.helpers import read_audio_pcm
from ._test_base import TestTasksBase
@fixture(scope="session")
def fxt_local_audio_with_cover() -> Generator[tuple[Path, Path], None, None]:
# Generate with:
# ffmpeg \
# -i "audio.mp3" \
# -i "cover.png" \
# -map 0:0 \
# -map 1:0 \
# -c copy \
# -id3v2_version 3 \
# "audio_with_cover.mp3"
yield (
SHARE_DIR / "audio" / "sample2_with_cover.mp3",
SHARE_DIR / "audio" / "sample2_cover.png",
)
@pytest.mark.usefixtures("restore_redis_ondisk_after_class")
@pytest.mark.usefixtures("restore_redis_ondisk_per_class")
@pytest.mark.usefixtures("restore_db_per_class")
@pytest.mark.usefixtures("restore_cvat_data_per_class")
class TestAudioTasks:
@pytest.fixture(autouse=True)
def setup(
self,
restore_redis_inmem_per_function,
tmp_path: Path,
admin_user: str,
):
self.tmp_dir = tmp_path
self.user = admin_user
with make_sdk_client(self.user) as client:
self.client = client
yield
@fixture(scope="class")
@parametrize("source_filename", [fixture_ref("fxt_local_audio_file_path")], scope="session")
def fxt_audio_task_from_uploaded_data(
cls, request: pytest.FixtureRequest, admin_user, source_filename: Path
):
with make_sdk_client(admin_user) as client:
yield client.tasks.create_from_data(
spec={
"name": f"{request.node.name}[{request.fixturename}]",
},
resources=[source_filename],
)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_create_audio_task_from_local_data(self, task: Task):
assert task.media_type == "audio"
assert task.dimension == "1d"
assert task.mode == "interpolation"
assert task.size > 0
@pytest.mark.with_external_services
@parametrize("use_cache", [True, False])
@parametrize(
"cloud_storage_id",
[
1, # public bucket
2, # private bucket
],
)
def test_can_create_audio_task_from_cloud_data(
self,
fxt_test_name: str,
fxt_uploaded_s3_file,
fxt_local_audio_file_path: Path,
use_cache: bool,
cloud_storage_id: int,
cloud_storages,
organizations,
):
cloud_storage = cloud_storages[cloud_storage_id]
org_id = cloud_storages[cloud_storage_id]["organization"]
org_slug = organizations[org_id]["slug"] if org_id else None
storage_media_path = PurePosixPath(fxt_local_audio_file_path.name)
s3_client = s3.make_client(bucket=cloud_storage["resource"])
fxt_uploaded_s3_file(
s3_client, path=storage_media_path, data=fxt_local_audio_file_path.read_bytes()
)
with self.client.organization_context(org_slug):
task = self.client.tasks.create_from_data(
spec={
"name": fxt_test_name,
},
resources=[storage_media_path],
resource_type=ResourceType.SHARE,
data_params={
"cloud_storage_id": cloud_storage_id,
"use_cache": use_cache,
},
)
assert task.media_type == "audio"
assert task.dimension == "1d"
assert task.mode == "interpolation"
assert task.size > 0
assert task.data_cloud_storage_id == cloud_storage_id
@parametrize(
"source_path, cover_image_path",
[
(fixture_ref("fxt_local_audio_file_path"), None),
fixture_ref(fxt_local_audio_with_cover),
],
)
def test_can_use_cover_image_for_preview(self, fxt_test_name, source_path, cover_image_path):
task = self.client.tasks.create_from_data(
spec={"name": fxt_test_name},
resources=[source_path],
)
jobs = task.get_jobs()
for instance in (task, *jobs):
actual = Image.open(instance.get_preview())
assert actual.size > (0, 0)
if cover_image_path is None:
assert actual.format == "PNG" # should return the default image
continue
expected = Image.open(cover_image_path)
expected.thumbnail((256, 256))
TestTasksBase._compare_images(expected, actual, must_be_identical=False)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_split_into_jobs(self, task: Task):
# Only 1 job is allowed for audio data
assert task.jobs.count == 1
@parametrize("source_filename", [fixture_ref("fxt_local_audio_file_path")])
def test_cant_use_segment_size(self, source_filename: Path, fxt_test_name: str):
with pytest.raises(BackgroundRequestException) as capture:
self.client.tasks.create_from_data(
spec={
"name": fxt_test_name,
"segment_size": 10000,
},
resources=[source_filename],
)
assert "'segment_size' parameter cannot be used in audio tasks" in str(capture.value)
@pytest.mark.timeout(
# This test has to check all the task chunks availability, it can make many requests
timeout=300
)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_get_task_chunks(self, task: Task):
data_meta = task.get_meta()
assert task.data_chunk_size == task.size
assert task.data_compressed_chunk_type == "audio_mp3"
assert task.data_original_chunk_type == "audio_mp3"
for quality, chunk_id in product(
["original", "compressed"],
range(math.ceil((data_meta.stop_frame - data_meta.start_frame) / data_meta.chunk_size)),
):
response = task.api.retrieve_data(
task.id, type="chunk", quality=quality, number=chunk_id, _parse_response=False
)[1]
chunk_file = io.BytesIO(response.data)
chunk_audio, sampling_rate = read_audio_pcm(chunk_file)
assert chunk_audio.shape[0] / sampling_rate >= data_meta.size / 1000
@pytest.mark.timeout(
# This test has to check all the job chunks availability, it can make many requests
timeout=300
)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
@parametrize("indexing", ["absolute", "relative"])
def test_can_get_job_chunks(self, task: Task, indexing: str):
jobs = sorted(task.get_jobs(), key=lambda j: j.start_frame)
assert len(jobs) == 1 # only 1 job is allowed per task so far
for job in jobs:
job_meta = job.get_meta()
assert job.data_chunk_size == job.frame_count
assert job.data_compressed_chunk_type == "audio_mp3"
assert job.data_original_chunk_type == "audio_mp3"
for quality, chunk_id in product(
["original", "compressed"],
range(math.ceil((job_meta.stop_frame - job_meta.start_frame) / job_meta.chunk_size)),
):
response = job.api.retrieve_data(
job.id,
type="chunk",
quality=quality,
**({"number": chunk_id} if indexing == "absolute" else {"index": chunk_id}),
_parse_response=False,
)[1]
chunk_file = io.BytesIO(response.data)
chunk_audio, sampling_rate = read_audio_pcm(chunk_file)
assert chunk_audio.shape[0] / sampling_rate >= job_meta.size / 1000
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_create_gt_job(self, task: Task):
gt_job = self.client.jobs.api.create(
job_write_request=models.JobWriteRequest(type="ground_truth", task_id=task.id)
)[0]
assert gt_job.type == "ground_truth"
assert gt_job.frame_count == task.size
@parametrize("source_filename", [fixture_ref("fxt_local_audio_file_path")])
def test_can_create_task_with_gt_job(self, fxt_test_name: str, source_filename: Path):
task = self.client.tasks.create_from_data(
spec={
"name": fxt_test_name,
},
resources=[source_filename],
data_params={"validation_params": {"mode": "gt"}},
)
gt_job = next(j for j in task.get_jobs() if j.type == "ground_truth")
assert gt_job.type == "ground_truth"
assert gt_job.frame_count == task.size
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_cant_export_dataset(self, task: Task):
with pytest.raises(BackgroundRequestException) as capture:
task.export_dataset("Generic TSV 1.0", filename=self.tmp_dir, include_images=True)
assert "export as dataset is not supported for audio" in str(capture.value)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_cant_import_dataset(self, task: Task, fxt_test_name: str):
project = self.client.projects.create({"name": fxt_test_name})
temp_file = self.tmp_dir / "test.tsv"
temp_file.write_text("test")
with pytest.raises(BackgroundRequestException) as capture:
project.import_dataset("Generic TSV 1.0", filename=temp_file)
assert "import from dataset is not supported for audio" in str(capture.value)
class TestAudioAnnotations:
@pytest.fixture(autouse=True)
def setup(
self,
restore_db_per_function,
admin_user: str,
):
self.user = admin_user
with make_sdk_client(self.user) as client:
self.client = client
yield
@parametrize("instance_type", ["task", "job"])
def test_can_save_intervals(self, tasks, instance_type: str):
task_id = next(t for t in tasks if t["media_type"] == "audio")["id"]
task = self.client.tasks.retrieve(task_id)
task.update({"labels": [{"name": "test", "type": "interval"}]})
label = task.get_labels()[-1]
payload = models.LabeledDataRequest(
intervals=[
models.LabeledIntervalRequest(
label_id=label.id,
start=0,
stop=task.size - 1,
),
]
)
if instance_type == "task":
instance = task
elif instance_type == "job":
instance = task.get_jobs()[0]
else:
assert False, instance_type
instance.set_annotations(payload)
server_annotations = instance.get_annotations()
assert len(server_annotations.intervals) == 1
assert server_annotations.intervals[0].label_id == payload.intervals[0].label_id
assert server_annotations.intervals[0].start == payload.intervals[0].start
assert server_annotations.intervals[0].stop == payload.intervals[0].stop
@parametrize("instance_type", ["task", "job"])
def test_can_save_interval_with_null_stop(self, tasks, instance_type: str):
task_id = next(t for t in tasks if t["media_type"] == "audio")["id"]
task = self.client.tasks.retrieve(task_id)
task.update({"labels": [{"name": "test", "type": "interval"}]})
label = task.get_labels()[-1]
payload = models.LabeledDataRequest(
intervals=[
models.LabeledIntervalRequest(
label_id=label.id,
start=0,
stop=None,
),
]
)
if instance_type == "task":
instance = task
elif instance_type == "job":
instance = task.get_jobs()[0]
else:
assert False, instance_type
instance.set_annotations(payload)
server_annotations = instance.get_annotations()
assert len(server_annotations.intervals) == 1
assert server_annotations.intervals[0].label_id == payload.intervals[0].label_id
assert server_annotations.intervals[0].start == payload.intervals[0].start
assert server_annotations.intervals[0].stop is None
@parametrize("instance_type", ["task", "job"])
def test_cant_save_intervals_outside_range(self, tasks, instance_type: str):
task_id = next(t for t in tasks if t["media_type"] == "audio")["id"]
task = self.client.tasks.retrieve(task_id)
labels = task.get_labels()
payload = models.LabeledDataRequest(
intervals=[
models.LabeledIntervalRequest(
label_id=labels[0].id,
start=0,
stop=task.size,
),
]
)
if instance_type == "task":
instance = task
elif instance_type == "job":
instance = task.get_jobs()[0]
else:
assert False, instance_type
with pytest.raises(exceptions.ApiException) as capture:
instance.set_annotations(payload)
assert "cannot be outside" in str(capture.value)