cvat-ai--cvat
2240 行
84 KiB
Python
2240 行
84 KiB
Python
# Copyright (C) 2018-2022 Intel Corporation
|
|
# Copyright (C) CVAT.ai Corporation
|
|
#
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
import concurrent.futures
|
|
import fnmatch
|
|
import itertools
|
|
import os
|
|
import shutil
|
|
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
from contextlib import closing
|
|
from copy import deepcopy
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path, PurePath, PurePosixPath
|
|
from typing import Any, NamedTuple, TypeAlias
|
|
from urllib import parse as urlparse
|
|
from urllib import request as urlrequest
|
|
|
|
import attrs
|
|
import av
|
|
import requests
|
|
import rq
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
from django.forms.models import model_to_dict
|
|
from rest_framework.serializers import ValidationError
|
|
|
|
from cvat.apps.engine import field_validation, models
|
|
from cvat.apps.engine.log import ServerLogManager
|
|
from cvat.apps.engine.media_extractors import (
|
|
MEDIA_TYPES,
|
|
AudioReader,
|
|
CachingMediaIterator,
|
|
ImageListReader,
|
|
IMediaReader,
|
|
Mpeg4ChunkWriter,
|
|
Mpeg4CompressedChunkWriter,
|
|
RandomAccessIterator,
|
|
ValidateDimension,
|
|
VideoReader,
|
|
ZipChunkWriter,
|
|
ZipCompressedChunkWriter,
|
|
get_mime,
|
|
load_image,
|
|
sort,
|
|
)
|
|
from cvat.apps.engine.media_io.audio_provider import TaskAudioProvider
|
|
from cvat.apps.engine.media_io.frame_provider import TaskFrameProvider
|
|
from cvat.apps.engine.rq import ImportRQMeta
|
|
from cvat.apps.engine.task_validation import HoneypotFrameSelector
|
|
from cvat.apps.engine.utils import av_scan_paths, format_list, get_path_size, take_by
|
|
from cvat.utils import django_database as db_utils
|
|
from cvat.utils.http import PROXIES_FOR_UNTRUSTED_URLS, make_requests_session
|
|
from cvat.utils.paths import join_untrusted_path, problem_with_untrusted_path
|
|
from utils.dataset_manifest import (
|
|
ImageManifestManager,
|
|
VideoManifestManager,
|
|
is_manifest,
|
|
)
|
|
from utils.dataset_manifest.core import VideoManifestValidator, is_dataset_manifest
|
|
from utils.dataset_manifest.utils import find_related_images
|
|
|
|
from .cloud_provider import HeaderFirstMediaDownloader, db_storage_to_storage_instance
|
|
|
|
slogger = ServerLogManager(__name__)
|
|
|
|
JobFileMapping: TypeAlias = list[list[str]]
|
|
|
|
MAX_AUDIO_DURATION = timedelta(hours=4)
|
|
|
|
|
|
class SegmentParams(NamedTuple):
|
|
start_frame: int
|
|
stop_frame: int
|
|
type: models.SegmentType = models.SegmentType.RANGE
|
|
frames: Sequence[int] | None = []
|
|
|
|
|
|
class SegmentsParams(NamedTuple):
|
|
segments: Iterator[SegmentParams]
|
|
segment_size: int
|
|
overlap: int
|
|
segments_count: int
|
|
|
|
|
|
def _copy_data_from_share_point(
|
|
server_files: list[str],
|
|
*,
|
|
update_status_callback: Callable[[str], None],
|
|
upload_dir: str,
|
|
server_dir: str | None = None,
|
|
server_files_exclude: list[str] | None = None,
|
|
):
|
|
update_status_callback("Data are being copied from source..")
|
|
|
|
filtered_server_files = server_files.copy()
|
|
|
|
# filter data from files/directories that should be excluded
|
|
if server_files_exclude:
|
|
for f in server_files:
|
|
path = Path(server_dir or settings.SHARE_ROOT) / f
|
|
if path.is_dir():
|
|
filtered_server_files.remove(f)
|
|
filtered_server_files.extend(
|
|
[str(f / i.relative_to(path)) for i in path.glob("**/*") if i.is_file()]
|
|
)
|
|
|
|
filtered_server_files = list(
|
|
filter(
|
|
lambda x: x not in server_files_exclude
|
|
and all([f"{i}/" not in server_files_exclude for i in Path(x).parents]),
|
|
filtered_server_files,
|
|
)
|
|
)
|
|
|
|
for path in filtered_server_files:
|
|
if server_dir is None:
|
|
source_path = os.path.join(settings.SHARE_ROOT, os.path.normpath(path))
|
|
else:
|
|
source_path = os.path.join(server_dir, os.path.normpath(path))
|
|
target_path = os.path.join(upload_dir, path)
|
|
if os.path.isdir(source_path):
|
|
shutil.copytree(source_path, target_path)
|
|
else:
|
|
target_dir = os.path.dirname(target_path)
|
|
if not os.path.exists(target_dir):
|
|
os.makedirs(target_dir)
|
|
shutil.copyfile(source_path, target_path)
|
|
|
|
|
|
def _generate_segment_params(
|
|
db_task: models.Task,
|
|
*,
|
|
data_size: int | None = None,
|
|
job_file_mapping: JobFileMapping | None = None,
|
|
) -> SegmentsParams:
|
|
if job_file_mapping is not None:
|
|
|
|
def _segments():
|
|
# It is assumed here that files are already saved ordered in the task
|
|
# Here we just need to create segments by the job sizes
|
|
start_frame = 0
|
|
for job_files in job_file_mapping:
|
|
segment_size = len(job_files)
|
|
stop_frame = start_frame + segment_size - 1
|
|
yield SegmentParams(
|
|
start_frame=start_frame,
|
|
stop_frame=stop_frame,
|
|
type=models.SegmentType.RANGE,
|
|
)
|
|
|
|
start_frame = stop_frame + 1
|
|
|
|
segments = _segments()
|
|
segment_size = 0
|
|
overlap = 0
|
|
segments_count = len(job_file_mapping)
|
|
else:
|
|
# The segments have equal parameters
|
|
if data_size is None:
|
|
data_size = db_task.data.size
|
|
|
|
segment_size = db_task.segment_size
|
|
if segment_size == 0 or segment_size > data_size:
|
|
segment_size = data_size
|
|
|
|
overlap = db_task.overlap
|
|
if overlap is None:
|
|
if data_size <= segment_size:
|
|
overlap = 0
|
|
elif (
|
|
db_task.media_type == models.MediaType.IMAGE
|
|
and db_task.mode == models.TaskMode.INTERPOLATION
|
|
):
|
|
overlap = 5
|
|
elif (
|
|
db_task.media_type == models.MediaType.AUDIO
|
|
and db_task.mode == models.TaskMode.INTERPOLATION
|
|
):
|
|
overlap = 10000
|
|
else:
|
|
overlap = 0
|
|
|
|
overlap = min(overlap, segment_size // 2)
|
|
segments_range = range(0, data_size - overlap, segment_size - overlap)
|
|
segments_count = len(segments_range)
|
|
|
|
segments = (
|
|
SegmentParams(
|
|
start_frame=start_frame,
|
|
stop_frame=min(start_frame + segment_size - 1, data_size - 1),
|
|
type=models.SegmentType.RANGE,
|
|
)
|
|
for start_frame in segments_range
|
|
)
|
|
|
|
return SegmentsParams(segments, segment_size, overlap, segments_count)
|
|
|
|
|
|
def _create_segments_and_jobs(
|
|
db_task: models.Task,
|
|
*,
|
|
update_status_callback: Callable[[str], None],
|
|
job_file_mapping: JobFileMapping | None = None,
|
|
):
|
|
update_status_callback("Task is being saved in database")
|
|
|
|
segments, segment_size, overlap, segments_count = _generate_segment_params(
|
|
db_task=db_task,
|
|
job_file_mapping=job_file_mapping,
|
|
)
|
|
db_task.segment_size = segment_size
|
|
db_task.overlap = overlap
|
|
|
|
job_count_total = segments_count * (db_task.consensus_replicas + 1)
|
|
if job_count_total > settings.MAX_JOBS_PER_TASK:
|
|
raise ValueError(
|
|
"Too many jobs would be created for the task. "
|
|
f"Current total: {job_count_total}, "
|
|
f"maximum allowed: {settings.MAX_JOBS_PER_TASK}."
|
|
)
|
|
|
|
for segment_idx, segment_params in enumerate(segments):
|
|
slogger.glob.info(
|
|
"New segment for task #{task_id}: idx = {segment_idx}, start_frame = {start_frame}, "
|
|
"stop_frame = {stop_frame}".format(
|
|
task_id=db_task.id, segment_idx=segment_idx, **segment_params._asdict()
|
|
)
|
|
)
|
|
|
|
db_segment = models.Segment(task=db_task, **segment_params._asdict())
|
|
db_segment.save()
|
|
|
|
db_job = models.Job(segment=db_segment)
|
|
db_job.save()
|
|
db_job.make_dirs()
|
|
|
|
# consensus jobs use the same `db_segment` as the regular job, thus data not duplicated in backups, exports
|
|
for _ in range(db_task.consensus_replicas):
|
|
consensus_db_job = models.Job(
|
|
segment=db_segment, parent_job_id=db_job.id, type=models.JobType.CONSENSUS_REPLICA
|
|
)
|
|
consensus_db_job.save()
|
|
consensus_db_job.make_dirs()
|
|
|
|
db_task.data.save()
|
|
db_task.save()
|
|
|
|
|
|
def _count_files(data: dict[str, Any]) -> dict[str, list[str]]:
|
|
share_root = settings.SHARE_ROOT
|
|
server_files = [f.rstrip("/") for f in data["server_files"]]
|
|
|
|
sorted_server_files = sorted(server_files, reverse=True)
|
|
# The idea of the code is trivial. After sort we will have files in the
|
|
# following order: 'a/b/c/d/2.txt', 'a/b/c/d/1.txt', 'a/b/c/d', 'a/b/c'
|
|
# Let's keep all items which aren't substrings of the previous item. In
|
|
# the example above only 2.txt and 1.txt files will be in the final list.
|
|
# Also need to correctly handle 'a/b/c0', 'a/b/c' case.
|
|
without_extra_dirs = [
|
|
v[1]
|
|
for v in zip([""] + sorted_server_files, sorted_server_files)
|
|
if not os.path.dirname(v[0]).startswith(v[1])
|
|
]
|
|
|
|
# we need to keep the original sequence of files
|
|
data["server_files"] = [f for f in server_files if f in without_extra_dirs]
|
|
|
|
def count_files(file_mapping, counter):
|
|
for rel_path, full_path in file_mapping.items():
|
|
mime = get_mime(full_path)
|
|
if mime in counter:
|
|
counter[mime].append(rel_path)
|
|
elif rel_path.endswith(".jsonl"):
|
|
continue
|
|
else:
|
|
slogger.glob.warn(
|
|
"Skip '{}' file (its mime type doesn't "
|
|
"correspond to supported MIME file type)".format(full_path)
|
|
)
|
|
|
|
counter = {media_type: [] for media_type in MEDIA_TYPES.keys()}
|
|
|
|
count_files(
|
|
file_mapping={f: f for f in data["remote_files"] or data["client_files"]},
|
|
counter=counter,
|
|
)
|
|
|
|
count_files(
|
|
file_mapping={f: join_untrusted_path(share_root, f) for f in data["server_files"]},
|
|
counter=counter,
|
|
)
|
|
|
|
return counter
|
|
|
|
|
|
def _find_manifest_files(data):
|
|
manifest_files = []
|
|
for files in ["client_files", "server_files", "remote_files"]:
|
|
current_manifest_files = list(filter(lambda x: x.endswith(".jsonl"), data[files]))
|
|
if current_manifest_files:
|
|
manifest_files.extend(current_manifest_files)
|
|
data[files] = [f for f in data[files] if f not in current_manifest_files]
|
|
return manifest_files
|
|
|
|
|
|
def _validate_data(counter: dict[str, list[str]], *, manifest_files: list[str] = None):
|
|
unique_entries = 0
|
|
multiple_entries = 0
|
|
for media_type, media_config in MEDIA_TYPES.items():
|
|
if counter[media_type]:
|
|
if media_config["unique"]:
|
|
unique_entries += len(counter[media_type])
|
|
else:
|
|
multiple_entries += len(counter[media_type])
|
|
|
|
if manifest_files and media_type not in ("video", "image", "zip", "archive"):
|
|
raise Exception(
|
|
"File with meta information can only be uploaded with video/images/archives"
|
|
)
|
|
|
|
if unique_entries == 1 and multiple_entries > 0 or unique_entries > 1:
|
|
unique_types = ", ".join([k for k, v in MEDIA_TYPES.items() if v["unique"]])
|
|
multiply_types = ", ".join([k for k, v in MEDIA_TYPES.items() if not v["unique"]])
|
|
count = ", ".join(["{} {}(s)".format(len(v), k) for k, v in counter.items()])
|
|
raise ValueError("Only one {} or many {} can be used simultaneously, \
|
|
but {} found.".format(unique_types, multiply_types, count))
|
|
|
|
if unique_entries == 0 and multiple_entries == 0:
|
|
raise ValueError("No media data found")
|
|
|
|
task_modes = [
|
|
MEDIA_TYPES[media_type]["mode"]
|
|
for media_type, media_files in counter.items()
|
|
if media_files
|
|
]
|
|
|
|
if not all(mode == task_modes[0] for mode in task_modes):
|
|
raise Exception("Could not combine different task modes for data")
|
|
|
|
return counter, task_modes[0]
|
|
|
|
|
|
def _validate_job_file_mapping(db_task: models.Task, data: dict[str, Any]) -> JobFileMapping | None:
|
|
job_file_mapping = data.get("job_file_mapping", None)
|
|
|
|
if job_file_mapping is None:
|
|
return None
|
|
|
|
if not list(itertools.chain.from_iterable(job_file_mapping)):
|
|
raise ValidationError("job_file_mapping cannot be empty")
|
|
|
|
if db_task.segment_size:
|
|
raise ValidationError("job_file_mapping cannot be used with segment_size")
|
|
|
|
if (
|
|
data.get("sorting_method", db_task.data.sorting_method)
|
|
!= models.SortingMethod.LEXICOGRAPHICAL
|
|
):
|
|
raise ValidationError("job_file_mapping cannot be used with sorting_method")
|
|
|
|
if data.get("start_frame", db_task.data.start_frame):
|
|
raise ValidationError("job_file_mapping cannot be used with start_frame")
|
|
|
|
if data.get("stop_frame", db_task.data.stop_frame):
|
|
raise ValidationError("job_file_mapping cannot be used with stop_frame")
|
|
|
|
if data.get("frame_filter", db_task.data.frame_filter):
|
|
raise ValidationError("job_file_mapping cannot be used with frame_filter")
|
|
|
|
if db_task.data.get_frame_step() != 1:
|
|
raise ValidationError("job_file_mapping cannot be used with frame step")
|
|
|
|
if data.get("filename_pattern"):
|
|
raise ValidationError("job_file_mapping cannot be used with filename_pattern")
|
|
|
|
if data.get("server_files_exclude"):
|
|
raise ValidationError("job_file_mapping cannot be used with server_files_exclude")
|
|
|
|
return job_file_mapping
|
|
|
|
|
|
def _validate_validation_params(
|
|
db_task: models.Task, data: dict[str, Any], *, is_backup_restore: bool = False
|
|
) -> dict[str, Any] | None:
|
|
params = data.get("validation_params", {})
|
|
if not params:
|
|
return None
|
|
|
|
if (
|
|
params["mode"] == models.ValidationMode.GT
|
|
and params.get("frame_selection_method") == models.JobFrameSelectionMethod.RANDOM_PER_JOB
|
|
and (frames_per_job := params.get("frames_per_job_count"))
|
|
and db_task.segment_size <= frames_per_job
|
|
):
|
|
raise ValidationError("Validation frame count per job cannot be greater than segment size")
|
|
|
|
if params["mode"] != models.ValidationMode.GT_POOL:
|
|
return params
|
|
|
|
if (
|
|
data.get("sorting_method", db_task.data.sorting_method) != models.SortingMethod.RANDOM
|
|
and not is_backup_restore
|
|
):
|
|
raise ValidationError(
|
|
'validation mode "{}" can only be used with "{}" sorting'.format(
|
|
models.ValidationMode.GT_POOL.value,
|
|
models.SortingMethod.RANDOM.value,
|
|
)
|
|
)
|
|
|
|
for incompatible_key in ["job_file_mapping", "overlap"]:
|
|
if incompatible_key == "job_file_mapping" and is_backup_restore:
|
|
continue
|
|
|
|
if data.get(incompatible_key):
|
|
raise ValidationError(
|
|
'validation mode "{}" cannot be used with "{}"'.format(
|
|
models.ValidationMode.GT_POOL.value,
|
|
incompatible_key,
|
|
)
|
|
)
|
|
|
|
return params
|
|
|
|
|
|
def _validate_manifest(
|
|
manifests: list[str],
|
|
root_dir: Path,
|
|
*,
|
|
is_in_cloud: bool,
|
|
db_cloud_storage: models.CloudStorage | None,
|
|
is_backup_restore: bool,
|
|
) -> str | None:
|
|
if not manifests:
|
|
return None
|
|
|
|
if len(manifests) != 1:
|
|
raise ValidationError("Only one manifest file can be attached to data")
|
|
manifest_file = manifests[0]
|
|
full_manifest_path = join_untrusted_path(root_dir, manifest_file)
|
|
|
|
if is_in_cloud and not is_backup_restore:
|
|
cloud_storage_instance = db_storage_to_storage_instance(db_cloud_storage)
|
|
# check that cloud storage manifest file exists and is up to date
|
|
if not full_manifest_path.exists() or (
|
|
datetime.fromtimestamp(full_manifest_path.stat().st_mtime, tz=timezone.utc)
|
|
< cloud_storage_instance.get_file_last_modified(manifest_file)
|
|
):
|
|
cloud_storage_instance.download_file(manifest_file, full_manifest_path)
|
|
|
|
if not is_manifest(full_manifest_path):
|
|
raise ValidationError("Invalid manifest was uploaded")
|
|
|
|
return manifest_file
|
|
|
|
|
|
def _validate_scheme(url):
|
|
ALLOWED_SCHEMES = ["http", "https"]
|
|
|
|
parsed_url = urlparse.urlparse(url)
|
|
|
|
if parsed_url.scheme not in ALLOWED_SCHEMES:
|
|
raise ValueError(
|
|
"Unsupported URL scheme: {}. Only http and https are supported".format(
|
|
parsed_url.scheme
|
|
)
|
|
)
|
|
|
|
|
|
class _FailedToDownloadFileError(Exception):
|
|
pass
|
|
|
|
|
|
def _download_data(
|
|
urls: Iterable[str],
|
|
upload_dir: str,
|
|
*,
|
|
update_status_callback: Callable[[str], None],
|
|
timeout: tuple[int, int] | None = (10, 60),
|
|
) -> list[str]:
|
|
local_files = {}
|
|
|
|
with make_requests_session() as session:
|
|
for url in urls:
|
|
name = os.path.basename(urlrequest.url2pathname(urlparse.urlparse(url).path))
|
|
if name in local_files:
|
|
raise _FailedToDownloadFileError("filename collision: {}".format(name))
|
|
|
|
_validate_scheme(url)
|
|
|
|
slogger.glob.info("Downloading: {}".format(url))
|
|
|
|
update_status_callback("{} is being downloaded..".format(url))
|
|
|
|
try:
|
|
response = session.get(
|
|
url,
|
|
stream=True,
|
|
proxies=PROXIES_FOR_UNTRUSTED_URLS,
|
|
timeout=timeout,
|
|
)
|
|
except requests.exceptions.RequestException as e:
|
|
raise _FailedToDownloadFileError(
|
|
f"Failed to download {url}: {e.__class__.__name__}: {e}"
|
|
) from e
|
|
|
|
if response.status_code == 200:
|
|
response.raw.decode_content = True
|
|
with open(os.path.join(upload_dir, name), "wb") as output_file:
|
|
shutil.copyfileobj(response.raw, output_file)
|
|
else:
|
|
error_message = f"Failed to download {response.url}"
|
|
if url != response.url:
|
|
error_message += f" (redirected from {url})"
|
|
|
|
if response.status_code == 407:
|
|
error_message += "; likely attempt to access internal host"
|
|
elif response.status_code:
|
|
error_message += f"; HTTP error {response.status_code}"
|
|
|
|
raise _FailedToDownloadFileError(error_message)
|
|
|
|
local_files[name] = True
|
|
|
|
return list(local_files.keys())
|
|
|
|
|
|
def _download_data_from_cloud_storage(
|
|
db_storage: models.CloudStorage,
|
|
files: Sequence[PurePath],
|
|
upload_dir: Path,
|
|
):
|
|
cloud_storage_instance = db_storage_to_storage_instance(db_storage)
|
|
cloud_storage_instance.bulk_download_to_dir(files, upload_dir)
|
|
|
|
|
|
def _read_dataset_manifest(path: Path, *, create_index: bool = False) -> ImageManifestManager:
|
|
"""
|
|
Reads an upload manifest file
|
|
"""
|
|
|
|
if not is_dataset_manifest(path):
|
|
raise ValidationError(
|
|
"Can't recognize a dataset manifest file in the uploaded file '{}'".format(path.name)
|
|
)
|
|
|
|
return ImageManifestManager(path, create_index=create_index)
|
|
|
|
|
|
def _restore_file_order_from_manifest(
|
|
extractor: ImageListReader, manifest: ImageManifestManager, upload_dir: str
|
|
) -> list[str]:
|
|
"""
|
|
Restores file ordering for the "predefined" file sorting method of the task creation.
|
|
Checks for extra files in the input.
|
|
Read more: https://github.com/cvat-ai/cvat/issues/5061
|
|
"""
|
|
|
|
input_files = {os.path.relpath(p, upload_dir): p for p in extractor.absolute_source_paths}
|
|
manifest_files = list(manifest.data)
|
|
|
|
mismatching_files = list(input_files.keys() ^ manifest_files)
|
|
if mismatching_files:
|
|
DISPLAY_ENTRIES_COUNT = 5
|
|
mismatching_display = [
|
|
fn + (" (upload)" if fn in input_files else " (manifest)")
|
|
for fn in mismatching_files[:DISPLAY_ENTRIES_COUNT]
|
|
]
|
|
remaining_count = len(mismatching_files) - DISPLAY_ENTRIES_COUNT
|
|
raise FileNotFoundError(
|
|
"Uploaded files do no match the upload manifest file contents. "
|
|
"Please check the upload manifest file contents and the list of uploaded files. "
|
|
"Mismatching files: {}{}. "
|
|
"Read more: https://docs.cvat.ai/docs/manual/advanced/dataset_manifest/".format(
|
|
", ".join(mismatching_display),
|
|
f" (and {remaining_count} more). " if 0 < remaining_count else "",
|
|
)
|
|
)
|
|
|
|
return [input_files[fn] for fn in manifest_files]
|
|
|
|
|
|
def _create_task_manifest_based_on_cloud_storage_manifest(
|
|
sorted_media: Sequence[PurePath],
|
|
cloud_storage_manifest_prefix: str,
|
|
cloud_storage_manifest: ImageManifestManager,
|
|
manifest: ImageManifestManager,
|
|
) -> None:
|
|
if cloud_storage_manifest_prefix:
|
|
sorted_media_without_manifest_prefix = [
|
|
i.relative_to(cloud_storage_manifest_prefix) for i in sorted_media
|
|
]
|
|
sequence, raw_content = cloud_storage_manifest.get_subset(
|
|
sorted_media_without_manifest_prefix
|
|
)
|
|
|
|
def _add_prefix(properties):
|
|
file_name = properties["name"]
|
|
properties["name"] = os.path.join(cloud_storage_manifest_prefix, file_name)
|
|
return properties
|
|
|
|
content = list(map(_add_prefix, raw_content))
|
|
else:
|
|
sequence, content = cloud_storage_manifest.get_subset(sorted_media)
|
|
|
|
if not content:
|
|
raise ValidationError(
|
|
"There is no intersection of the files specified"
|
|
"in the request with the contents of the bucket"
|
|
)
|
|
|
|
sorted_content = (i[1] for i in sorted(zip(sequence, content)))
|
|
manifest.create(sorted_content)
|
|
|
|
|
|
def _create_task_manifest_from_cloud_data(
|
|
db_storage: models.CloudStorage,
|
|
sorted_media: Sequence[PurePath],
|
|
manifest: ImageManifestManager,
|
|
) -> None:
|
|
dimension = ValidateDimension().detect_dimension_for_paths(sorted_media)
|
|
|
|
regular_images, related_images = find_related_images(
|
|
sorted_media,
|
|
# backward compatibility, deprecated in https://github.com/cvat-ai/cvat/pull/9757
|
|
is_scene_path=(lambda p: not "related_images" in p.parts),
|
|
)
|
|
sorted_media = [f for f in sorted_media if f in regular_images]
|
|
|
|
storage_client = db_storage_to_storage_instance(db_storage)
|
|
content_generator = storage_client.bulk_download_to_memory(
|
|
list(map(os.fspath, sorted_media)),
|
|
object_downloader=HeaderFirstMediaDownloader.create(
|
|
dimension=dimension, client=storage_client
|
|
).download,
|
|
)
|
|
|
|
manifest.link(
|
|
sources=content_generator,
|
|
meta={
|
|
os.fspath(k): {"related_images": [ri.as_posix() for ri in related_images[k]]}
|
|
for k in related_images
|
|
},
|
|
DIM_3D=(dimension == models.DimensionType.DIM_3D),
|
|
stop=len(sorted_media) - 1,
|
|
data_dir=".",
|
|
)
|
|
manifest.create()
|
|
|
|
|
|
def _find_and_filter_related_images(
|
|
extractor: ImageListReader, *, upload_dir: str
|
|
) -> dict[str, list[str]]:
|
|
regular_images, related_images = find_related_images(
|
|
extractor.absolute_source_paths,
|
|
# backward compatibility
|
|
is_scene_path=(lambda p: "related_images" not in p.parts),
|
|
)
|
|
|
|
# extractor.filter() uses absolute paths, so we pass them
|
|
extractor.filter(lambda p: p in regular_images)
|
|
|
|
# manifest requires relative files as they would be in the task data, so update the paths
|
|
return {
|
|
os.path.relpath(k, upload_dir): [os.path.relpath(ri, upload_dir) for ri in k_ris]
|
|
for k, k_ris in related_images.items()
|
|
}
|
|
|
|
|
|
def _allocate_honeypots(
|
|
db_task: models.Task,
|
|
validation_params: dict[str, Any] | None,
|
|
*,
|
|
images: list[models.Image],
|
|
manifest: ImageManifestManager,
|
|
job_file_mapping: JobFileMapping | None,
|
|
is_backup_restore: bool,
|
|
) -> tuple[JobFileMapping | None, list[models.Image]]:
|
|
if not validation_params or validation_params["mode"] != models.ValidationMode.GT_POOL:
|
|
return job_file_mapping, images
|
|
|
|
db_data = db_task.require_data()
|
|
|
|
if is_backup_restore:
|
|
# Validation frames must be in the end of the images list. Collect their ids
|
|
frame_idx_map: dict[str, int] = {}
|
|
for i, frame_filename in enumerate(validation_params["frames"]):
|
|
image = images[-len(validation_params["frames"]) + i]
|
|
assert frame_filename == image.path
|
|
frame_idx_map[image.path] = image.frame
|
|
|
|
# Store information about the real frame placement in validation frames in jobs
|
|
for image in images[: -len(validation_params["frames"])]:
|
|
real_frame = frame_idx_map.get(image.path)
|
|
if real_frame is not None:
|
|
image.is_placeholder = True
|
|
image.real_frame = real_frame
|
|
|
|
# Exclude the previous GT job from the list of jobs to be created with normal segments
|
|
# It must be the last one
|
|
assert job_file_mapping[-1] == validation_params["frames"]
|
|
job_file_mapping.pop(-1)
|
|
|
|
db_data.update_validation_layout(
|
|
models.ValidationLayout(
|
|
mode=models.ValidationMode.GT_POOL,
|
|
frames=list(frame_idx_map.values()),
|
|
frames_per_job_count=validation_params["frames_per_job_count"],
|
|
)
|
|
)
|
|
else:
|
|
if db_task.mode != models.TaskMode.ANNOTATION:
|
|
raise ValidationError(
|
|
f"validation mode '{models.ValidationMode.GT_POOL}' can only be used "
|
|
f"with '{models.TaskMode.ANNOTATION}' mode tasks"
|
|
)
|
|
|
|
# 1. select pool frames
|
|
all_frames = range(len(images))
|
|
|
|
# The RNG backend must not change to yield reproducible frame picks,
|
|
# so here we specify it explicitly
|
|
from numpy import random
|
|
|
|
seed = validation_params.get("random_seed")
|
|
rng = random.Generator(random.MT19937(seed=seed))
|
|
|
|
# Sort the images to be able to create reproducible results
|
|
images = sort(images, sorting_method=models.SortingMethod.NATURAL, func=lambda i: i.path)
|
|
for i, image in enumerate(images):
|
|
image.frame = i
|
|
|
|
pool_frames: list[int] = []
|
|
match validation_params["frame_selection_method"]:
|
|
case models.JobFrameSelectionMethod.RANDOM_UNIFORM:
|
|
if frame_count := validation_params.get("frame_count"):
|
|
if len(images) <= frame_count:
|
|
raise ValidationError(
|
|
f"The number of validation frames requested ({frame_count}) "
|
|
f"must be less than the number of task frames ({len(images)})"
|
|
)
|
|
elif frame_share := validation_params.get("frame_share"):
|
|
frame_count = max(1, int(len(images) * frame_share))
|
|
else:
|
|
raise ValidationError("The number of validation frames is not specified")
|
|
|
|
pool_frames = rng.choice(
|
|
all_frames, size=frame_count, shuffle=False, replace=False
|
|
).tolist()
|
|
case models.JobFrameSelectionMethod.MANUAL:
|
|
known_frame_names = {frame.path: frame.frame for frame in images}
|
|
unknown_requested_frames = []
|
|
for frame_filename in validation_params["frames"]:
|
|
frame_id = known_frame_names.get(frame_filename)
|
|
if frame_id is None:
|
|
unknown_requested_frames.append(frame_filename)
|
|
continue
|
|
|
|
pool_frames.append(frame_id)
|
|
|
|
if unknown_requested_frames:
|
|
raise ValidationError(
|
|
"Unknown validation frames requested: {}".format(
|
|
format_list(sorted(unknown_requested_frames))
|
|
)
|
|
)
|
|
case _:
|
|
assert False
|
|
|
|
if len(all_frames) - len(pool_frames) < 1:
|
|
raise ValidationError(
|
|
"Cannot create task: "
|
|
"too few non-honeypot frames left after selecting validation frames"
|
|
)
|
|
|
|
# Even though the sorting is random overall,
|
|
# it's convenient to be able to reasonably navigate in the GT job
|
|
pool_frames = sort(
|
|
pool_frames,
|
|
sorting_method=models.SortingMethod.NATURAL,
|
|
func=lambda frame: images[frame].path,
|
|
)
|
|
|
|
# 2. distribute pool frames
|
|
if frames_per_job_count := validation_params.get("frames_per_job_count"):
|
|
if len(pool_frames) < frames_per_job_count and validation_params.get("frame_count"):
|
|
raise ValidationError(
|
|
f"The requested number of validation frames per job ({frames_per_job_count}) "
|
|
f"is greater than the validation pool size ({len(pool_frames)})"
|
|
)
|
|
elif frames_per_job_share := validation_params.get("frames_per_job_share"):
|
|
frames_per_job_count = max(1, int(frames_per_job_share * db_task.segment_size))
|
|
else:
|
|
raise ValidationError("The number of validation frames is not specified")
|
|
|
|
frames_per_job_count = min(len(pool_frames), frames_per_job_count)
|
|
|
|
non_pool_frames = sorted(
|
|
# set() doesn't guarantee ordering,
|
|
# so sort additionally before shuffling to make results reproducible
|
|
set(all_frames).difference(pool_frames)
|
|
)
|
|
rng.shuffle(non_pool_frames)
|
|
|
|
validation_frame_counts = {f: 0 for f in pool_frames}
|
|
frame_selector = HoneypotFrameSelector(validation_frame_counts, rng=rng)
|
|
|
|
# Don't use the same rng as for frame ordering to simplify random_seed maintenance in future
|
|
# We still use the same seed, but in this case the frame selection rng is separate
|
|
# from job frame ordering rng
|
|
job_frame_ordering_rng = random.Generator(random.MT19937(seed=seed))
|
|
|
|
# Allocate frames for jobs
|
|
job_file_mapping: JobFileMapping = []
|
|
new_db_images: list[models.Image] = []
|
|
validation_frames: list[int] = []
|
|
frame_idx_map: dict[int, int] = {} # new to original id
|
|
for job_frames in take_by(non_pool_frames, chunk_size=db_task.segment_size or db_data.size):
|
|
job_validation_frames = list(frame_selector.select_next_frames(frames_per_job_count))
|
|
job_frames += job_validation_frames
|
|
|
|
job_frame_ordering_rng.shuffle(job_frames)
|
|
|
|
job_images = []
|
|
for job_frame in job_frames:
|
|
# Insert placeholder frames into the frame sequence and shift frame ids
|
|
image = images[job_frame]
|
|
image = models.Image(
|
|
data=db_data, **deepcopy(model_to_dict(image, exclude=["data"]))
|
|
)
|
|
image.frame = len(new_db_images)
|
|
|
|
if job_frame in job_validation_frames:
|
|
image.is_placeholder = True
|
|
image.real_frame = job_frame
|
|
validation_frames.append(image.frame)
|
|
|
|
job_images.append(image.path)
|
|
new_db_images.append(image)
|
|
frame_idx_map[image.frame] = job_frame
|
|
|
|
job_file_mapping.append(job_images)
|
|
|
|
# Append pool frames in the end, shift their ids, establish placeholder pointers
|
|
frame_id_map: dict[int, int] = {} # original to new id
|
|
for pool_frame in pool_frames:
|
|
# Insert placeholder frames into the frame sequence and shift frame ids
|
|
image = images[pool_frame]
|
|
image = models.Image(data=db_data, **deepcopy(model_to_dict(image, exclude=["data"])))
|
|
new_frame_id = len(new_db_images)
|
|
image.frame = new_frame_id
|
|
|
|
frame_id_map[pool_frame] = new_frame_id
|
|
|
|
new_db_images.append(image)
|
|
frame_idx_map[image.frame] = pool_frame
|
|
|
|
pool_frames = [frame_id_map[i] for i in pool_frames if i in frame_id_map]
|
|
|
|
# Store information about the real frame placement in the validation frames
|
|
for validation_frame in validation_frames:
|
|
image = new_db_images[validation_frame]
|
|
assert image.is_placeholder
|
|
image.real_frame = frame_id_map[image.real_frame]
|
|
|
|
# Update manifest
|
|
manifest.reorder([images[frame_idx_map[image.frame]].path for image in new_db_images])
|
|
|
|
images = new_db_images
|
|
db_data.size = len(images)
|
|
db_data.start_frame = 0
|
|
db_data.stop_frame = 0
|
|
db_data.frame_filter = ""
|
|
|
|
db_data.update_validation_layout(
|
|
models.ValidationLayout(
|
|
mode=models.ValidationMode.GT_POOL,
|
|
frames=pool_frames,
|
|
frames_per_job_count=frames_per_job_count,
|
|
)
|
|
)
|
|
|
|
return job_file_mapping, images
|
|
|
|
|
|
def _create_validation_jobs(
|
|
db_task: models.Task,
|
|
validation_params: dict[str, Any] | None,
|
|
*,
|
|
images: list[models.Image] | None,
|
|
) -> None:
|
|
db_data = db_task.require_data()
|
|
|
|
if db_task.media_type == models.MediaType.AUDIO and (
|
|
validation_params and validation_params["mode"] != models.ValidationMode.GT
|
|
):
|
|
raise ValidationError(
|
|
"Only the '{}' validation mode is available in '{}' tasks.".format(
|
|
models.ValidationMode.GT, models.MediaType.AUDIO
|
|
)
|
|
)
|
|
|
|
if db_task.media_type == models.MediaType.POINT_CLOUD and (
|
|
validation_params and validation_params["mode"]
|
|
):
|
|
raise ValidationError(f"Validation is not available in '{db_task.media_type}' tasks.")
|
|
|
|
if validation_params and validation_params["mode"] == models.ValidationMode.GT:
|
|
|
|
def _to_rel_frame(abs_frame: int) -> int:
|
|
return (abs_frame - db_data.start_frame) // db_data.get_frame_step()
|
|
|
|
if db_task.media_type == models.MediaType.AUDIO:
|
|
if "frame_selection_method" in validation_params:
|
|
field_validation.require_one_of_values(
|
|
validation_params, "frame_selection_method", ["random_uniform"]
|
|
)
|
|
validation_params.pop("frame_selection_method")
|
|
|
|
if "frames" in validation_params:
|
|
if not validation_params["frames"]:
|
|
validation_params.pop("frames")
|
|
|
|
if extra_params := set(validation_params.keys()) - {"mode"}:
|
|
raise ValidationError(
|
|
"Validation parameters {} are not applicable to the '{}' media type.".format(
|
|
", ".join(f"'{v}'" for v in extra_params),
|
|
db_task.media_type,
|
|
)
|
|
)
|
|
|
|
validation_frames = []
|
|
else:
|
|
field_validation.require_field(validation_params, "frame_selection_method")
|
|
|
|
# The RNG backend must not change to yield reproducible frame picks,
|
|
# so here we specify it explicitly
|
|
from numpy import random
|
|
|
|
seed = validation_params.get("random_seed")
|
|
rng = random.Generator(random.MT19937(seed=seed))
|
|
|
|
match validation_params["frame_selection_method"]:
|
|
case models.JobFrameSelectionMethod.RANDOM_UNIFORM:
|
|
all_frames = range(db_data.size)
|
|
|
|
if frame_count := validation_params.get("frame_count"):
|
|
if db_data.size < frame_count:
|
|
raise ValidationError(
|
|
f"The number of validation frames requested ({frame_count}) "
|
|
f"is greater that the number of task frames ({db_data.size})"
|
|
)
|
|
elif frame_share := validation_params.get("frame_share"):
|
|
frame_count = max(1, int(frame_share * len(all_frames)))
|
|
else:
|
|
raise ValidationError("The number of validation frames is not specified")
|
|
|
|
validation_frames = rng.choice(
|
|
all_frames, size=frame_count, shuffle=False, replace=False
|
|
).tolist()
|
|
case models.JobFrameSelectionMethod.RANDOM_PER_JOB:
|
|
if frame_count := validation_params.get("frames_per_job_count"):
|
|
if db_task.segment_size < frame_count:
|
|
raise ValidationError(
|
|
"The requested number of GT frames per job must be less "
|
|
f"than task segment size ({db_task.segment_size})"
|
|
)
|
|
elif frame_share := validation_params.get("frames_per_job_share"):
|
|
frame_count = min(
|
|
max(1, int(frame_share * db_task.segment_size)), db_data.size
|
|
)
|
|
else:
|
|
raise ValidationError("The number of validation frames is not specified")
|
|
|
|
validation_frames: list[int] = []
|
|
overlap = db_task.overlap
|
|
for segment in db_task.segment_set.all():
|
|
segment_frames = set(map(_to_rel_frame, segment.frame_set))
|
|
selected_frames = segment_frames.intersection(validation_frames)
|
|
selected_count = len(selected_frames)
|
|
|
|
missing_count = min(len(segment_frames), frame_count) - selected_count
|
|
if missing_count <= 0:
|
|
continue
|
|
|
|
selectable_segment_frames = set(
|
|
sorted(segment_frames)[overlap * (segment.start_frame != 0) :]
|
|
).difference(selected_frames)
|
|
|
|
validation_frames.extend(
|
|
rng.choice(
|
|
tuple(selectable_segment_frames), size=missing_count, replace=False
|
|
).tolist()
|
|
)
|
|
case models.JobFrameSelectionMethod.MANUAL:
|
|
if not images:
|
|
raise ValidationError(
|
|
"{} validation frame selection method at task creation "
|
|
"is only available for image-based tasks. "
|
|
"Please create the GT job after the task is created.".format(
|
|
models.JobFrameSelectionMethod.MANUAL
|
|
)
|
|
)
|
|
|
|
validation_frames: list[int] = []
|
|
known_frame_names = {frame.path: _to_rel_frame(frame.frame) for frame in images}
|
|
unknown_requested_frames = []
|
|
for frame_filename in validation_params["frames"]:
|
|
frame_id = known_frame_names.get(frame_filename)
|
|
if frame_id is None:
|
|
unknown_requested_frames.append(frame_filename)
|
|
continue
|
|
|
|
validation_frames.append(frame_id)
|
|
|
|
if unknown_requested_frames:
|
|
raise ValidationError(
|
|
"Unknown validation frames requested: {}".format(
|
|
format_list(sorted(unknown_requested_frames))
|
|
)
|
|
)
|
|
case _:
|
|
assert (
|
|
False
|
|
), f'Unknown frame selection method {validation_params["frame_selection_method"]}'
|
|
|
|
db_data.update_validation_layout(
|
|
models.ValidationLayout(
|
|
mode=models.ValidationMode.GT,
|
|
frames=sorted(validation_frames),
|
|
)
|
|
)
|
|
|
|
if hasattr(db_data, "validation_layout"):
|
|
if db_data.validation_layout.mode == models.ValidationMode.GT:
|
|
|
|
def _to_abs_frame(rel_frame: int) -> int:
|
|
return rel_frame * db_data.get_frame_step() + db_data.start_frame
|
|
|
|
db_gt_segment = models.Segment(
|
|
task=db_task,
|
|
start_frame=0,
|
|
stop_frame=db_data.size - 1,
|
|
frames=list(map(_to_abs_frame, db_data.validation_layout.frames)),
|
|
type=(
|
|
models.SegmentType.SPECIFIC_FRAMES
|
|
if db_data.validation_layout.frames
|
|
else models.SegmentType.RANGE
|
|
),
|
|
)
|
|
elif db_data.validation_layout.mode == models.ValidationMode.GT_POOL:
|
|
db_gt_segment = models.Segment(
|
|
task=db_task,
|
|
start_frame=min(db_data.validation_layout.frames),
|
|
stop_frame=max(db_data.validation_layout.frames),
|
|
type=models.SegmentType.RANGE,
|
|
)
|
|
else:
|
|
assert False
|
|
|
|
db_gt_segment.save()
|
|
|
|
db_gt_job = models.Job(segment=db_gt_segment, type=models.JobType.GROUND_TRUTH)
|
|
db_gt_job.save()
|
|
db_gt_job.make_dirs()
|
|
|
|
|
|
def _filter_cloud_storage_files(
|
|
cloud_storage: models.CloudStorage,
|
|
data: dict[str, Any],
|
|
*,
|
|
job_file_mapping: JobFileMapping | None,
|
|
cloud_storage_manifest_prefix: str | None,
|
|
cloud_storage_manifest: ImageManifestManager | None,
|
|
) -> None:
|
|
cloud_storage_instance = db_storage_to_storage_instance(cloud_storage)
|
|
|
|
if cloud_storage_manifest and not data["server_files"] and not data["filename_pattern"]:
|
|
# only manifest file was specified in server files by the user
|
|
data["filename_pattern"] = "*"
|
|
|
|
# update the server_files list with files from the specified directories
|
|
if dirs := list(filter(lambda x: x.endswith("/"), data["server_files"])):
|
|
copy_of_server_files = data["server_files"].copy()
|
|
copy_of_dirs = dirs.copy()
|
|
additional_files = []
|
|
if cloud_storage_manifest:
|
|
for directory in dirs:
|
|
if cloud_storage_manifest_prefix:
|
|
# cloud_storage_manifest_prefix is a dirname of manifest,
|
|
# it doesn't end with a slash
|
|
directory = directory[len(cloud_storage_manifest_prefix) + 1 :]
|
|
|
|
additional_files.extend(
|
|
[
|
|
x[1].full_name
|
|
for x in filter(
|
|
lambda x: x[1].full_name.startswith(directory),
|
|
cloud_storage_manifest,
|
|
)
|
|
]
|
|
if directory
|
|
else [x[1].full_name for x in cloud_storage_manifest]
|
|
)
|
|
|
|
if cloud_storage_manifest_prefix:
|
|
additional_files = [
|
|
os.path.join(cloud_storage_manifest_prefix, f) for f in additional_files
|
|
]
|
|
else:
|
|
while len(dirs):
|
|
directory = dirs.pop()
|
|
for f in cloud_storage_instance.list_files(
|
|
prefix=directory, _use_flat_listing=True
|
|
):
|
|
if f["type"] == "REG":
|
|
if problem_with_untrusted_path(f["name"]):
|
|
continue
|
|
|
|
additional_files.append(f["name"])
|
|
else:
|
|
dirs.append(f["name"])
|
|
|
|
data["server_files"] = []
|
|
for f in copy_of_server_files:
|
|
if f not in copy_of_dirs:
|
|
data["server_files"].append(f)
|
|
else:
|
|
data["server_files"].extend(
|
|
list(filter(lambda x: x.startswith(f), additional_files))
|
|
)
|
|
|
|
del additional_files
|
|
|
|
if server_files_exclude := data.get("server_files_exclude"):
|
|
data["server_files"] = list(
|
|
filter(
|
|
lambda x: x not in server_files_exclude
|
|
and all([f"{i}/" not in server_files_exclude for i in Path(x).parents]),
|
|
data["server_files"],
|
|
)
|
|
)
|
|
|
|
# update list with server files if task creation approach with pattern and manifest file is used
|
|
if data["filename_pattern"]:
|
|
additional_files = []
|
|
|
|
if not cloud_storage_manifest:
|
|
# NOTE: we cannot list files with specified pattern on the providers page,
|
|
# because they don't provide such function
|
|
dirs = []
|
|
prefix = ""
|
|
|
|
while True:
|
|
for f in cloud_storage_instance.list_files(prefix=prefix, _use_flat_listing=True):
|
|
if f["type"] == "REG":
|
|
if problem_with_untrusted_path(f["name"]):
|
|
continue
|
|
|
|
additional_files.append(f["name"])
|
|
else:
|
|
dirs.append(f["name"])
|
|
if not dirs:
|
|
break
|
|
prefix = dirs.pop()
|
|
|
|
if not data["filename_pattern"] == "*":
|
|
additional_files = fnmatch.filter(additional_files, data["filename_pattern"])
|
|
else:
|
|
additional_files = (
|
|
list(cloud_storage_manifest.data)
|
|
if not cloud_storage_manifest_prefix
|
|
else [
|
|
os.path.join(cloud_storage_manifest_prefix, f)
|
|
for f in cloud_storage_manifest.data
|
|
]
|
|
)
|
|
if not data["filename_pattern"] == "*":
|
|
additional_files = fnmatch.filter(additional_files, data["filename_pattern"])
|
|
|
|
data["server_files"].extend(additional_files)
|
|
|
|
if cloud_storage_instance.prefix:
|
|
# filter server_files based on default prefix
|
|
data["server_files"] = list(
|
|
filter(lambda x: x.startswith(cloud_storage_instance.prefix), data["server_files"])
|
|
)
|
|
|
|
if job_file_mapping is not None:
|
|
# We only need to process the files specified in job_file_mapping
|
|
filtered_files = []
|
|
for f in itertools.chain.from_iterable(job_file_mapping):
|
|
if f not in data["server_files"]:
|
|
raise ValidationError(f"Job mapping file {f} is not specified in input files")
|
|
filtered_files.append(f)
|
|
|
|
data["server_files"] = filtered_files
|
|
|
|
|
|
def _detect_media_type_and_dimension(
|
|
extractor: IMediaReader, *, source_dir: Path, db_data: models.Data
|
|
) -> tuple[models.MediaType, models.DimensionType]:
|
|
if isinstance(extractor, MEDIA_TYPES["video"]["extractor"]):
|
|
detected_media_type = models.MediaType.IMAGE
|
|
detected_dimension = models.DimensionType.DIM_2D
|
|
elif isinstance(extractor, MEDIA_TYPES["audio"]["extractor"]):
|
|
# TODO: support audio reading from video?
|
|
detected_media_type = models.MediaType.AUDIO
|
|
detected_dimension = models.DimensionType.DIM_1D
|
|
else:
|
|
validate_dimension = ValidateDimension()
|
|
if db_data.storage == models.StorageChoice.LOCAL or (
|
|
db_data.storage == models.StorageChoice.SHARE
|
|
and isinstance(
|
|
extractor, (MEDIA_TYPES["archive"]["extractor"], MEDIA_TYPES["zip"]["extractor"])
|
|
)
|
|
):
|
|
validate_dimension.validate(source_dir)
|
|
else:
|
|
validate_dimension.detect_dimension_for_paths(extractor.absolute_source_paths)
|
|
|
|
detected_dimension = validate_dimension.dimension
|
|
|
|
if detected_dimension == models.DimensionType.DIM_2D:
|
|
detected_media_type = models.MediaType.IMAGE
|
|
elif detected_dimension == models.DimensionType.DIM_3D:
|
|
detected_media_type = models.MediaType.POINT_CLOUD
|
|
else:
|
|
assert False
|
|
|
|
return detected_media_type, detected_dimension
|
|
|
|
|
|
def _validate_project_media_type(
|
|
db_project: models.Project, *, detected_media_type: models.MediaType
|
|
):
|
|
# TODO: fix the race condition between concurrent task creations
|
|
project_media_type = next(
|
|
iter(db_project.tasks.exclude(media_type="").values_list("media_type", flat=True)[:1]), ""
|
|
)
|
|
|
|
if project_media_type and project_media_type != detected_media_type:
|
|
raise ValidationError(
|
|
f"Media type ({detected_media_type}) of the task must be compatible "
|
|
f"with other tasks in the project ({project_media_type})"
|
|
)
|
|
|
|
|
|
def _configure_chunk_types(db_task: models.Task, data: dict[str, Any]) -> None:
|
|
db_data = db_task.require_data()
|
|
|
|
match (db_task.media_type, db_task.mode):
|
|
case (models.MediaType.AUDIO, models.TaskMode.INTERPOLATION):
|
|
db_data.compressed_chunk_type = models.DataChoice.AUDIO_MP3
|
|
db_data.original_chunk_type = models.DataChoice.AUDIO_MP3
|
|
case (models.MediaType.IMAGE, models.TaskMode.INTERPOLATION):
|
|
db_data.compressed_chunk_type = (
|
|
models.DataChoice.IMAGESET if data["use_zip_chunks"] else models.DataChoice.VIDEO
|
|
)
|
|
db_data.original_chunk_type = models.DataChoice.VIDEO
|
|
case (models.MediaType.IMAGE | models.MediaType.POINT_CLOUD, models.TaskMode.ANNOTATION):
|
|
db_data.compressed_chunk_type = models.DataChoice.IMAGESET
|
|
db_data.original_chunk_type = models.DataChoice.IMAGESET
|
|
case (media_type, mode):
|
|
assert False, f"Unexpected media type '{media_type}' with mode '{mode}'"
|
|
|
|
|
|
def _collect_video_dataset_descriptors(
|
|
extractor: VideoReader,
|
|
video_path: Path,
|
|
*,
|
|
db_data: models.Data,
|
|
data: dict[str, Any],
|
|
manifest_file: str | None,
|
|
manifest_frame_alignment: int,
|
|
upload_dir: Path,
|
|
update_status: Callable[[str], None],
|
|
) -> tuple[models.Video, int, VideoManifestManager]:
|
|
if manifest_file:
|
|
try:
|
|
update_status("Validating the input manifest file")
|
|
|
|
manifest = VideoManifestValidator(
|
|
source_path=video_path,
|
|
manifest_path=db_data.get_manifest_path(),
|
|
)
|
|
manifest.init_index()
|
|
manifest.validate_seek_key_frames()
|
|
|
|
if not len(manifest):
|
|
raise ValidationError("No key frames found in the manifest")
|
|
|
|
except Exception as ex:
|
|
manifest.remove()
|
|
manifest = None
|
|
|
|
if isinstance(ex, (ValidationError, AssertionError)):
|
|
base_msg = f"Invalid manifest file was uploaded: {ex}"
|
|
else:
|
|
base_msg = "Failed to parse the uploaded manifest file"
|
|
slogger.glob.warning(ex, exc_info=True)
|
|
|
|
update_status(base_msg)
|
|
else:
|
|
manifest = None
|
|
|
|
if not manifest:
|
|
try:
|
|
update_status("Preparing a manifest file")
|
|
|
|
# TODO: maybe generate manifest in a temp directory
|
|
manifest = VideoManifestManager(db_data.get_manifest_path())
|
|
manifest.link(
|
|
media_file=video_path,
|
|
chunk_size=manifest_frame_alignment, # TODO: try to remove
|
|
force=True,
|
|
)
|
|
manifest.create()
|
|
|
|
update_status("A manifest has been created")
|
|
|
|
except Exception as ex:
|
|
manifest.remove()
|
|
manifest = None
|
|
|
|
if isinstance(ex, AssertionError):
|
|
base_msg = f": {ex}"
|
|
else:
|
|
base_msg = ""
|
|
slogger.glob.warning(ex, exc_info=True)
|
|
|
|
update_status(
|
|
f"Failed to create manifest for the uploaded video{base_msg}. "
|
|
"A manifest will not be used in this task"
|
|
)
|
|
|
|
if manifest:
|
|
video_frame_count = manifest.video_length
|
|
video_frame_size = manifest.video_resolution
|
|
else:
|
|
video_frame_count = extractor.get_frame_count()
|
|
video_frame_size = extractor.get_image_size(0)
|
|
|
|
video_length = len(
|
|
range(
|
|
db_data.start_frame,
|
|
min(
|
|
data["stop_frame"] + 1 if data["stop_frame"] else video_frame_count,
|
|
video_frame_count,
|
|
),
|
|
db_data.get_frame_step(),
|
|
)
|
|
)
|
|
|
|
video = models.Video(
|
|
data=db_data,
|
|
path=video_path.relative_to(upload_dir).as_posix(),
|
|
width=video_frame_size[0],
|
|
height=video_frame_size[1],
|
|
)
|
|
|
|
return video, video_length, manifest
|
|
|
|
|
|
def _create_video_task_media_descriptors(
|
|
db_task: models.Task,
|
|
data: dict[str, Any],
|
|
*,
|
|
extractor: IMediaReader,
|
|
media: dict[str, Any],
|
|
upload_dir: Path,
|
|
manifest_file: str | None,
|
|
update_status: Callable[[str], None],
|
|
) -> tuple[models.Video, VideoManifestManager]:
|
|
db_data = db_task.require_data()
|
|
|
|
video, video_length, manifest = _collect_video_dataset_descriptors(
|
|
extractor=extractor,
|
|
video_path=upload_dir / media["video"][0],
|
|
upload_dir=upload_dir,
|
|
db_data=db_data,
|
|
data=data,
|
|
manifest_file=manifest_file,
|
|
manifest_frame_alignment=db_data.chunk_size,
|
|
update_status=update_status,
|
|
)
|
|
db_data.size = video_length
|
|
|
|
video.save()
|
|
|
|
return video, manifest
|
|
|
|
|
|
def _collect_image_dataset_descriptors(
|
|
extractor: ImageListReader,
|
|
*,
|
|
db_task: models.Task,
|
|
upload_dir: Path,
|
|
is_data_in_cloud: bool,
|
|
related_images: dict[str, list[str]],
|
|
) -> tuple[list[models.Image], ImageManifestManager]:
|
|
db_data = db_task.require_data()
|
|
|
|
manifest = ImageManifestManager(db_data.get_manifest_path())
|
|
if not manifest.exists:
|
|
# TODO: Try to avoid adding manifest entries for images that are not in
|
|
# extractor.frame_range. In addition to less processing here, it would also allow
|
|
# us to avoid downloading such images from cloud storage (when using static chunks),
|
|
# or copying them from the attached share (when using copy_data).
|
|
manifest.link(
|
|
sources=extractor.absolute_source_paths,
|
|
meta={k: {"related_images": related_images[k]} for k in related_images},
|
|
data_dir=upload_dir,
|
|
DIM_3D=(db_task.dimension == models.DimensionType.DIM_3D),
|
|
)
|
|
manifest.create()
|
|
else:
|
|
manifest.init_index()
|
|
|
|
images: list[models.Image] = []
|
|
for frame_id in extractor.frame_range:
|
|
image_path = extractor.get_path(frame_id).relative_to(upload_dir).as_posix()
|
|
image_size = None
|
|
|
|
if manifest:
|
|
image_info = manifest[frame_id]
|
|
|
|
# check mapping
|
|
manifest_image_path = f"{image_info['name']}{image_info['extension']}"
|
|
if image_path != manifest_image_path and not image_path.endswith(
|
|
"/" + manifest_image_path
|
|
):
|
|
raise ValidationError("Incorrect file mapping to manifest content")
|
|
|
|
if image_info.get("width") is not None and image_info.get("height") is not None:
|
|
image_size = (image_info["width"], image_info["height"])
|
|
elif is_data_in_cloud:
|
|
raise ValidationError(
|
|
"Can't find image '{}' width or height info in the manifest".format(
|
|
manifest_image_path
|
|
)
|
|
)
|
|
|
|
if not image_size:
|
|
image_size = extractor.get_image_size(frame_id)
|
|
|
|
images.append(
|
|
models.Image(
|
|
data=db_data,
|
|
path=image_path,
|
|
frame=frame_id,
|
|
width=image_size[0],
|
|
height=image_size[1],
|
|
)
|
|
)
|
|
|
|
return images, manifest
|
|
|
|
|
|
def _create_image_task_media_descriptors(
|
|
db_task: models.Task,
|
|
*,
|
|
is_backup_restore: bool,
|
|
validation_params: dict[str, Any],
|
|
upload_dir: Path,
|
|
is_data_in_cloud: bool,
|
|
extractor: IMediaReader,
|
|
related_images: dict[str, Sequence[dict[str, Any]]],
|
|
job_file_mapping: JobFileMapping | None,
|
|
) -> tuple[list[models.Image], ImageManifestManager, JobFileMapping | None]:
|
|
db_data = db_task.require_data()
|
|
|
|
images, manifest = _collect_image_dataset_descriptors(
|
|
extractor=extractor,
|
|
related_images=related_images,
|
|
upload_dir=upload_dir,
|
|
db_task=db_task,
|
|
is_data_in_cloud=is_data_in_cloud,
|
|
)
|
|
db_data.size = len(images)
|
|
|
|
job_file_mapping, images = _allocate_honeypots(
|
|
db_task,
|
|
validation_params,
|
|
images=images,
|
|
manifest=manifest,
|
|
job_file_mapping=job_file_mapping,
|
|
is_backup_restore=is_backup_restore,
|
|
)
|
|
|
|
images = db_utils.bulk_create(models.Image, images)
|
|
|
|
db_related_files = [
|
|
models.RelatedFile(
|
|
data=db_data,
|
|
path=related_file_path,
|
|
)
|
|
for related_file_path in set(itertools.chain.from_iterable(related_images.values()))
|
|
]
|
|
|
|
db_related_files = db_utils.bulk_create(models.RelatedFile, db_related_files)
|
|
db_related_files_by_path = {rf.path: rf for rf in db_related_files}
|
|
|
|
ThroughModel = models.RelatedFile.images.through
|
|
db_utils.bulk_create(
|
|
ThroughModel,
|
|
(
|
|
ThroughModel(
|
|
relatedfile_id=db_related_files_by_path[related_file_path].id,
|
|
image_id=image.id,
|
|
)
|
|
for image in images
|
|
for related_file_path in related_images.get(image.path, [])
|
|
),
|
|
)
|
|
|
|
return images, manifest, job_file_mapping
|
|
|
|
|
|
def _collect_audio_dataset_descriptors(
|
|
extractor: AudioReader, *, db_data: models.Data, upload_dir: Path, audio_path: Path
|
|
) -> tuple[models.Audio, int]:
|
|
audio = models.Audio(
|
|
data=db_data,
|
|
path=audio_path.relative_to(upload_dir),
|
|
sampling_rate=extractor.sampling_rate,
|
|
has_cover_image=extractor.get_preview_image() is not None,
|
|
)
|
|
|
|
if MAX_AUDIO_DURATION.total_seconds() < extractor.duration:
|
|
raise ValidationError(f"Audio files longer than {MAX_AUDIO_DURATION} are not allowed")
|
|
|
|
return audio, extractor.length
|
|
|
|
|
|
def _create_audio_task_media_descriptors(
|
|
db_task: models.Task, *, extractor: AudioReader, upload_dir: Path, media: dict[str, Any]
|
|
) -> models.Audio:
|
|
db_data = db_task.require_data()
|
|
|
|
audio, audio_length = _collect_audio_dataset_descriptors(
|
|
extractor=extractor,
|
|
audio_path=upload_dir / media["audio"][0],
|
|
upload_dir=upload_dir,
|
|
db_data=db_data,
|
|
)
|
|
db_data.size = audio_length
|
|
db_data.chunk_size = audio_length # the UI can't handle chunks yet
|
|
|
|
audio.save()
|
|
|
|
return audio
|
|
|
|
|
|
def ensure_task_is_initialized(task: models.Task) -> None:
|
|
if not task.media_type:
|
|
raise ValidationError("This task data has not been initialized yet. Please try again later")
|
|
|
|
|
|
@transaction.atomic
|
|
def initialize_task(
|
|
db_task: int | models.Task,
|
|
data: dict[str, Any],
|
|
*,
|
|
is_backup_restore: bool = False,
|
|
) -> None:
|
|
if isinstance(db_task, int):
|
|
db_task = (
|
|
models.Task.objects.exclude(data=None)
|
|
.select_related("data")
|
|
.select_for_update(of=("self", "data"))
|
|
.get(pk=db_task)
|
|
)
|
|
|
|
if db_task.data.cloud_storage_id is not None:
|
|
db_task.data.cloud_storage = db_utils.get_object_by_id_for_share(
|
|
model=models.CloudStorage,
|
|
object_id=db_task.data.cloud_storage_id,
|
|
)
|
|
|
|
slogger.glob.info("create task #{}".format(db_task.id))
|
|
|
|
job = rq.get_current_job()
|
|
rq_job_meta = ImportRQMeta.for_job(job)
|
|
|
|
def update_status(msg: str) -> None:
|
|
rq_job_meta.status = msg
|
|
rq_job_meta.save()
|
|
|
|
job_file_mapping = _validate_job_file_mapping(db_task, data)
|
|
|
|
validation_params = _validate_validation_params(
|
|
db_task, data, is_backup_restore=is_backup_restore
|
|
)
|
|
|
|
db_data = db_task.require_data()
|
|
upload_dir = (
|
|
db_data.get_upload_dirname()
|
|
if db_data.storage != models.StorageChoice.SHARE
|
|
else settings.SHARE_ROOT
|
|
)
|
|
is_data_in_cloud = db_data.storage == models.StorageChoice.CLOUD_STORAGE
|
|
|
|
if data["remote_files"]:
|
|
try:
|
|
data["remote_files"] = _download_data(
|
|
data["remote_files"], upload_dir, update_status_callback=update_status
|
|
)
|
|
except _FailedToDownloadFileError as e:
|
|
slogger.glob.exception("Failed to download remote files")
|
|
raise ValidationError(str(e)) from e
|
|
|
|
# find and validate manifest file
|
|
manifest_files = _find_manifest_files(data)
|
|
manifest_root: Path
|
|
|
|
# we should also handle this case because files from the share source have not been downloaded yet
|
|
if data["copy_data"]:
|
|
manifest_root = settings.SHARE_ROOT
|
|
elif db_data.storage in {models.StorageChoice.LOCAL, models.StorageChoice.SHARE}:
|
|
manifest_root = upload_dir
|
|
elif is_data_in_cloud and is_backup_restore:
|
|
manifest_root = upload_dir
|
|
elif is_data_in_cloud:
|
|
manifest_root = db_data.cloud_storage.get_storage_dirname()
|
|
else:
|
|
assert False, f"Unknown file storage {db_data.storage}"
|
|
|
|
manifest_file = _validate_manifest(
|
|
manifest_files,
|
|
manifest_root,
|
|
is_in_cloud=is_data_in_cloud,
|
|
db_cloud_storage=db_data.cloud_storage if is_data_in_cloud else None,
|
|
is_backup_restore=is_backup_restore,
|
|
)
|
|
|
|
if is_data_in_cloud and not is_backup_restore:
|
|
cloud_storage_manifest: ImageManifestManager | None = None
|
|
cloud_storage_manifest_prefix: str | None = None
|
|
if manifest_file:
|
|
cloud_storage_manifest = ImageManifestManager(
|
|
db_data.cloud_storage.get_storage_dirname() / manifest_file,
|
|
db_data.cloud_storage.get_storage_dirname(),
|
|
)
|
|
cloud_storage_manifest.set_index()
|
|
cloud_storage_manifest_prefix = os.path.dirname(manifest_file)
|
|
|
|
_filter_cloud_storage_files(
|
|
db_data.cloud_storage,
|
|
data,
|
|
job_file_mapping=job_file_mapping,
|
|
cloud_storage_manifest=cloud_storage_manifest,
|
|
cloud_storage_manifest_prefix=cloud_storage_manifest_prefix,
|
|
)
|
|
|
|
# count and validate uploaded files
|
|
media = _count_files(data)
|
|
media, detected_mode = _validate_data(media, manifest_files=manifest_files)
|
|
is_media_sorted = False
|
|
|
|
if job_file_mapping is not None and detected_mode != models.TaskMode.ANNOTATION:
|
|
raise ValidationError("job_file_mapping can't be used with sequence-based data like videos")
|
|
|
|
if (
|
|
(
|
|
db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM
|
|
and not settings.MEDIA_CACHE_ALLOW_STATIC_CACHE
|
|
)
|
|
or (
|
|
# static cache can not be initialized on lightweight backup restore
|
|
is_data_in_cloud
|
|
and is_backup_restore
|
|
and db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM
|
|
)
|
|
or (
|
|
# TODO: Not supported yet, maybe implement later
|
|
media["audio"]
|
|
)
|
|
):
|
|
db_data.storage_method = models.StorageMethodChoice.CACHE
|
|
|
|
manifest = None
|
|
if is_data_in_cloud:
|
|
is_packed_media = any(v for k, v in media.items() if k != "image")
|
|
if (
|
|
# Download remote data if local storage is requested
|
|
# TODO: maybe move into cache building to fail faster on invalid task configurations
|
|
db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM
|
|
# Packed media must be downloaded for task creation
|
|
or is_packed_media
|
|
):
|
|
update_status("Downloading input media")
|
|
|
|
_download_data_from_cloud_storage(
|
|
db_storage=db_data.cloud_storage,
|
|
files=list(map(PurePosixPath, itertools.chain.from_iterable(media.values()))),
|
|
upload_dir=upload_dir,
|
|
)
|
|
|
|
is_data_in_cloud = False
|
|
if is_packed_media:
|
|
db_data.storage = models.StorageChoice.LOCAL
|
|
else:
|
|
manifest = ImageManifestManager(db_data.get_manifest_path())
|
|
|
|
if data["server_files"]:
|
|
if db_data.storage == models.StorageChoice.LOCAL and not db_data.cloud_storage:
|
|
# this means that the data has not been downloaded from the storage to the host
|
|
_copy_data_from_share_point(
|
|
(data["server_files"] + [manifest_file]) if manifest_file else data["server_files"],
|
|
upload_dir=upload_dir,
|
|
server_dir=data.get("server_files_path"),
|
|
server_files_exclude=data.get("server_files_exclude"),
|
|
update_status_callback=update_status,
|
|
)
|
|
manifest_root = upload_dir
|
|
elif is_data_in_cloud:
|
|
# we should sort media before sorting in the extractor because the manifest structure should match to the sorted media
|
|
if job_file_mapping is not None:
|
|
sorted_media = list(itertools.chain.from_iterable(job_file_mapping))
|
|
else:
|
|
sorted_media = sort(media["image"], data["sorting_method"])
|
|
media["image"] = sorted_media
|
|
|
|
sorted_media = [PurePosixPath(f) for f in sorted_media]
|
|
is_media_sorted = True
|
|
|
|
if manifest_file:
|
|
if not is_backup_restore:
|
|
# Define task manifest content based on cloud storage manifest content and uploaded files
|
|
_create_task_manifest_based_on_cloud_storage_manifest(
|
|
sorted_media=sorted_media,
|
|
cloud_storage_manifest_prefix=cloud_storage_manifest_prefix,
|
|
cloud_storage_manifest=cloud_storage_manifest,
|
|
manifest=manifest,
|
|
)
|
|
else: # without manifest file but with use_cache option
|
|
# Define task manifest content based on list with uploaded files
|
|
_create_task_manifest_from_cloud_data(
|
|
db_storage=db_data.cloud_storage,
|
|
sorted_media=sorted_media,
|
|
manifest=manifest,
|
|
)
|
|
|
|
av_scan_paths(upload_dir)
|
|
|
|
# If something was uploaded to the raw directory, update content_size in the Data model
|
|
# raw_data_dir and upload_dir are not always the same
|
|
raw_data_dir = db_data.get_upload_dirname()
|
|
if os.path.exists(raw_data_dir):
|
|
try:
|
|
db_data.content_size = get_path_size(raw_data_dir)
|
|
db_data.save()
|
|
except Exception:
|
|
slogger.glob.warning(
|
|
f"Could not calculate raw data size for created task #{db_task.id}", exc_info=True
|
|
)
|
|
|
|
update_status("Media files are being extracted...")
|
|
|
|
# If upload from server_files image and directories
|
|
# need to update images list by all found images in directories
|
|
if (data["server_files"]) and len(media["directory"]) and len(media["image"]):
|
|
media["image"].extend(
|
|
[
|
|
os.path.relpath(image, upload_dir)
|
|
for image in MEDIA_TYPES["directory"]["extractor"](
|
|
source_paths=[upload_dir / f for f in media["directory"]],
|
|
).absolute_source_paths
|
|
]
|
|
)
|
|
media["directory"] = []
|
|
|
|
if (
|
|
not is_backup_restore
|
|
and manifest_file
|
|
and data["sorting_method"] == models.SortingMethod.RANDOM
|
|
):
|
|
raise ValidationError("It isn't supported to upload manifest file and use random sorting")
|
|
|
|
if (
|
|
is_backup_restore
|
|
and db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM
|
|
and data["sorting_method"] in {models.SortingMethod.RANDOM, models.SortingMethod.PREDEFINED}
|
|
):
|
|
raise ValidationError(
|
|
"It isn't supported to import the task that was created "
|
|
"without cache but with random/predefined sorting"
|
|
)
|
|
|
|
# Extract input data
|
|
extractor: IMediaReader | None = None
|
|
for media_type, media_files in media.items():
|
|
if not media_files:
|
|
continue
|
|
|
|
if extractor is not None:
|
|
raise ValidationError("Combined data types are not supported")
|
|
|
|
source_paths = [upload_dir / f for f in media_files]
|
|
|
|
details = {
|
|
"source_paths": source_paths,
|
|
"start": db_data.start_frame,
|
|
"stop": data["stop_frame"],
|
|
}
|
|
|
|
if (
|
|
media_type in {"archive", "zip", "pdf"}
|
|
and db_data.storage == models.StorageChoice.SHARE
|
|
):
|
|
details["extract_dir"] = db_data.get_upload_dirname()
|
|
upload_dir = db_data.get_upload_dirname()
|
|
db_data.storage = models.StorageChoice.LOCAL
|
|
|
|
if MEDIA_TYPES[media_type]["mode"] == models.TaskMode.ANNOTATION:
|
|
details["sorting_method"] = (
|
|
data["sorting_method"] if not is_media_sorted else models.SortingMethod.PREDEFINED
|
|
)
|
|
|
|
if media_type != "audio":
|
|
details["step"] = db_data.get_frame_step()
|
|
|
|
extractor = MEDIA_TYPES[media_type]["extractor"](**details)
|
|
|
|
if extractor is None:
|
|
raise ValidationError("Can't create a task without data")
|
|
|
|
# filter server_files from server_files_exclude when share point is used and files are not copied to CVAT.
|
|
# here we exclude the case when the files are copied to CVAT because files are already filtered out.
|
|
if (
|
|
(server_files_exclude := data.get("server_files_exclude"))
|
|
and data["server_files"]
|
|
and not is_data_in_cloud
|
|
and not data["copy_data"]
|
|
and isinstance(extractor, MEDIA_TYPES["image"]["extractor"])
|
|
):
|
|
extractor.filter(
|
|
lambda x: os.path.relpath(x, upload_dir) not in server_files_exclude
|
|
and all(
|
|
[
|
|
f"{i}/" not in server_files_exclude
|
|
for i in Path(x).relative_to(upload_dir).parents
|
|
]
|
|
)
|
|
)
|
|
|
|
if isinstance(extractor, MEDIA_TYPES["zip"]["extractor"]):
|
|
extractor.extract()
|
|
|
|
detected_media_type, detected_dimension = _detect_media_type_and_dimension(
|
|
extractor=extractor, source_dir=upload_dir, db_data=db_data
|
|
)
|
|
|
|
if db_task.project_id is not None:
|
|
_validate_project_media_type(db_task.project, detected_media_type=detected_media_type)
|
|
|
|
assert not db_task.media_type
|
|
db_task.media_type = detected_media_type
|
|
db_task.dimension = detected_dimension
|
|
db_task.mode = detected_mode
|
|
|
|
if db_task.dimension == models.DimensionType.DIM_3D:
|
|
extractor.reconcile(
|
|
source_paths=[
|
|
# We always work with .pcd files instead of .bin
|
|
p.with_suffix(".pcd") if p.suffix == ".bin" else p
|
|
for p in extractor.absolute_source_paths
|
|
],
|
|
step=db_data.get_frame_step(),
|
|
start=db_data.start_frame,
|
|
stop=data["stop_frame"],
|
|
dimension=db_task.dimension,
|
|
)
|
|
|
|
related_images = {}
|
|
if isinstance(extractor, MEDIA_TYPES["image"]["extractor"]):
|
|
related_images = _find_and_filter_related_images(extractor, upload_dir=upload_dir)
|
|
|
|
if job_file_mapping or (
|
|
(
|
|
not isinstance(
|
|
extractor, (MEDIA_TYPES["video"]["extractor"], MEDIA_TYPES["audio"]["extractor"])
|
|
)
|
|
and is_backup_restore
|
|
and db_data.storage_method == models.StorageMethodChoice.CACHE
|
|
and db_data.sorting_method
|
|
in {models.SortingMethod.RANDOM, models.SortingMethod.PREDEFINED}
|
|
)
|
|
or (
|
|
not is_backup_restore
|
|
and data["sorting_method"] == models.SortingMethod.PREDEFINED
|
|
and (
|
|
# Sorting with manifest is required for zip
|
|
isinstance(extractor, MEDIA_TYPES["zip"]["extractor"])
|
|
# Sorting with manifest is optional for non-video
|
|
or (manifest_file or manifest)
|
|
and not isinstance(
|
|
extractor,
|
|
(MEDIA_TYPES["video"]["extractor"], MEDIA_TYPES["audio"]["extractor"]),
|
|
)
|
|
)
|
|
)
|
|
):
|
|
if job_file_mapping:
|
|
# Sort media_files according to the requested file order
|
|
sorted_media_files = itertools.chain.from_iterable(job_file_mapping)
|
|
|
|
else:
|
|
# Sort media_files according to the manifest file order
|
|
if manifest is None:
|
|
if not manifest_file or not os.path.isfile(
|
|
os.path.join(manifest_root, manifest_file)
|
|
):
|
|
raise FileNotFoundError(
|
|
"Can't find upload manifest file '{}' "
|
|
"in the uploaded files. When the 'predefined' sorting method is used, "
|
|
"this file is required in the input files. "
|
|
"Read more: https://docs.cvat.ai/docs/manual/advanced/dataset_manifest/".format(
|
|
manifest_file or os.path.basename(db_data.get_manifest_path())
|
|
)
|
|
)
|
|
|
|
manifest = _read_dataset_manifest(
|
|
manifest_root / manifest_file,
|
|
create_index=manifest_root.is_relative_to(db_data.get_upload_dirname()),
|
|
)
|
|
|
|
sorted_media_files = _restore_file_order_from_manifest(extractor, manifest, upload_dir)
|
|
|
|
sorted_media_files = [upload_dir / fn for fn in sorted_media_files]
|
|
|
|
# validate the sorting
|
|
for file_path in sorted_media_files:
|
|
if file_path not in extractor:
|
|
raise ValidationError(f"Can't find file '{file_path.name}' in the input files")
|
|
|
|
media_files = sorted_media_files.copy()
|
|
del sorted_media_files
|
|
|
|
data["sorting_method"] = models.SortingMethod.PREDEFINED
|
|
extractor.reconcile(
|
|
source_paths=media_files,
|
|
step=db_data.get_frame_step(),
|
|
start=db_data.start_frame,
|
|
stop=data["stop_frame"],
|
|
sorting_method=data["sorting_method"],
|
|
)
|
|
|
|
if isinstance(extractor, MEDIA_TYPES["audio"]["extractor"]):
|
|
if job_file_mapping:
|
|
raise ValidationError("The 'job_file_mapping' parameter cannot be used in audio tasks")
|
|
|
|
if not is_backup_restore and (db_task.segment_size or data.get("segment_size")):
|
|
raise ValidationError("The 'segment_size' parameter cannot be used in audio tasks")
|
|
|
|
# replace manifest file (e.g was uploaded 'subdir/manifest.jsonl' or 'some_manifest.jsonl')
|
|
if manifest_file and not os.path.exists(db_data.get_manifest_path()):
|
|
shutil.copyfile(os.path.join(manifest_root, manifest_file), db_data.get_manifest_path())
|
|
if manifest_root and manifest_root.is_relative_to(db_data.get_upload_dirname()):
|
|
os.remove(os.path.join(manifest_root, manifest_file))
|
|
manifest_file = os.path.relpath(db_data.get_manifest_path(), upload_dir)
|
|
|
|
_configure_chunk_types(db_task, data)
|
|
|
|
# calculate chunk size if it isn't specified
|
|
if db_data.chunk_size is None:
|
|
if db_data.compressed_chunk_type == models.DataChoice.IMAGESET:
|
|
first_image_idx = db_data.start_frame
|
|
if not is_data_in_cloud:
|
|
w, h = extractor.get_image_size(first_image_idx)
|
|
else:
|
|
img_properties = manifest[first_image_idx]
|
|
w, h = img_properties["width"], img_properties["height"]
|
|
area = h * w
|
|
db_data.chunk_size = max(2, min(72, 36 * 1920 * 1080 // area))
|
|
else:
|
|
db_data.chunk_size = 36
|
|
|
|
if db_task.media_type == models.MediaType.IMAGE and not data.get(
|
|
"image_quality", db_data.image_quality
|
|
):
|
|
raise ValidationError(
|
|
"The 'image_quality' parameter is required for image- and video-based tasks"
|
|
)
|
|
|
|
# Create task media descriptors from the metadata collected
|
|
images = None
|
|
match (db_task.media_type, db_task.mode):
|
|
case (models.MediaType.IMAGE, models.TaskMode.INTERPOLATION):
|
|
_create_video_task_media_descriptors(
|
|
db_task,
|
|
data,
|
|
extractor=extractor,
|
|
media=media,
|
|
upload_dir=upload_dir,
|
|
manifest_file=manifest_file,
|
|
update_status=update_status,
|
|
)
|
|
case (models.MediaType.IMAGE | models.MediaType.POINT_CLOUD, models.TaskMode.ANNOTATION):
|
|
images, _, job_file_mapping = _create_image_task_media_descriptors(
|
|
db_task,
|
|
extractor=extractor,
|
|
related_images=related_images,
|
|
validation_params=validation_params,
|
|
job_file_mapping=job_file_mapping,
|
|
upload_dir=upload_dir,
|
|
is_backup_restore=is_backup_restore,
|
|
is_data_in_cloud=is_data_in_cloud,
|
|
)
|
|
case (models.MediaType.AUDIO, models.TaskMode.INTERPOLATION):
|
|
_create_audio_task_media_descriptors(
|
|
db_task,
|
|
media=media,
|
|
extractor=extractor,
|
|
upload_dir=upload_dir,
|
|
)
|
|
case (media_type, mode):
|
|
assert False, f"Unexpected media type '{media_type}' with mode '{mode}'"
|
|
|
|
# validate stop_frame
|
|
if db_data.stop_frame == 0:
|
|
db_data.stop_frame = db_data.start_frame + (db_data.size - 1) * db_data.get_frame_step()
|
|
else:
|
|
db_data.stop_frame = min(
|
|
db_data.stop_frame, db_data.start_frame + (db_data.size - 1) * db_data.get_frame_step()
|
|
)
|
|
|
|
slogger.glob.info(
|
|
"Saved media for Data #{}: media type '{}', {} frames".format(
|
|
db_data.id,
|
|
db_task.media_type,
|
|
db_data.size,
|
|
)
|
|
)
|
|
|
|
_create_segments_and_jobs(
|
|
db_task, job_file_mapping=job_file_mapping, update_status_callback=update_status
|
|
)
|
|
_create_validation_jobs(db_task, validation_params, images=images)
|
|
|
|
db_task.save()
|
|
|
|
if (
|
|
settings.MEDIA_CACHE_ALLOW_STATIC_CACHE
|
|
and db_data.storage_method == models.StorageMethodChoice.FILE_SYSTEM
|
|
):
|
|
_create_static_chunks(db_task, media_extractor=extractor, upload_dir=upload_dir)
|
|
|
|
if not (is_data_in_cloud and is_backup_restore):
|
|
_create_task_preview(db_task)
|
|
|
|
_move_to_backing_cs_if_configured(db_data)
|
|
|
|
|
|
def _create_task_preview(db_task: models.Task):
|
|
# Prepare the preview image and save it in the cache
|
|
match db_task.media_type:
|
|
case models.MediaType.AUDIO:
|
|
TaskAudioProvider(db_task).get_preview_image()
|
|
case models.MediaType.IMAGE | models.MediaType.POINT_CLOUD:
|
|
TaskFrameProvider(db_task).get_preview_image()
|
|
case _ as media_type:
|
|
assert False, f"Unknown media type '{media_type}'"
|
|
|
|
|
|
def _create_static_chunks(
|
|
db_task: models.Task, *, media_extractor: IMediaReader, upload_dir: Path
|
|
) -> None:
|
|
@attrs.define
|
|
class _ChunkProgressUpdater:
|
|
_call_counter: int = attrs.field(default=0, init=False)
|
|
_rq_job: rq.job.Job = attrs.field(factory=rq.get_current_job)
|
|
|
|
def update_progress(self, progress: float):
|
|
progress_animation = "|/-\\"
|
|
|
|
status_message = "CVAT is preparing data chunks"
|
|
if not progress:
|
|
status_message = "{} {}".format(
|
|
status_message, progress_animation[self._call_counter]
|
|
)
|
|
|
|
rq_job_meta = ImportRQMeta.for_job(self._rq_job)
|
|
rq_job_meta.status = status_message
|
|
rq_job_meta.task_progress = progress or 0.0
|
|
rq_job_meta.save()
|
|
|
|
self._call_counter = (self._call_counter + 1) % len(progress_animation)
|
|
|
|
def save_chunks(
|
|
executor: concurrent.futures.ThreadPoolExecutor,
|
|
db_segment: models.Segment,
|
|
chunk_idx: int,
|
|
chunk_frame_ids: Sequence[int],
|
|
):
|
|
chunk_data = [media_iterator[frame_idx] for frame_idx in chunk_frame_ids]
|
|
|
|
if db_task.dimension == models.DimensionType.DIM_2D and isinstance(
|
|
media_extractor,
|
|
(
|
|
MEDIA_TYPES["image"]["extractor"],
|
|
MEDIA_TYPES["zip"]["extractor"],
|
|
MEDIA_TYPES["pdf"]["extractor"],
|
|
MEDIA_TYPES["archive"]["extractor"],
|
|
),
|
|
):
|
|
chunk_data = list(map(load_image, chunk_data))
|
|
|
|
# TODO: extract into a class
|
|
|
|
fs_original = executor.submit(
|
|
original_chunk_writer.save_as_chunk,
|
|
images=chunk_data,
|
|
chunk_path=db_data.get_static_segment_chunk_path(
|
|
chunk_idx, segment_id=db_segment.id, quality=models.FrameQuality.ORIGINAL
|
|
),
|
|
)
|
|
compressed_chunk_writer.save_as_chunk(
|
|
images=chunk_data,
|
|
chunk_path=db_data.get_static_segment_chunk_path(
|
|
chunk_idx, segment_id=db_segment.id, quality=models.FrameQuality.COMPRESSED
|
|
),
|
|
)
|
|
|
|
fs_original.result()
|
|
|
|
assert db_task.media_type in (
|
|
models.MediaType.IMAGE,
|
|
models.MediaType.POINT_CLOUD,
|
|
)
|
|
|
|
db_data = db_task.require_data()
|
|
|
|
if db_data.compressed_chunk_type == models.DataChoice.VIDEO:
|
|
compressed_chunk_writer_class = Mpeg4CompressedChunkWriter
|
|
else:
|
|
compressed_chunk_writer_class = ZipCompressedChunkWriter
|
|
|
|
if db_data.original_chunk_type == models.DataChoice.VIDEO:
|
|
original_chunk_writer_class = Mpeg4ChunkWriter
|
|
|
|
# Let's use QP=17 (that is 67 for 0-100 range) for the original chunks,
|
|
# which should be visually lossless or nearly so.
|
|
# A lower value will significantly increase the chunk size with a slight increase of quality.
|
|
original_quality = 67 # TODO: fix discrepancy in values in different parts of code
|
|
else:
|
|
original_chunk_writer_class = ZipChunkWriter
|
|
original_quality = 100
|
|
|
|
compressed_chunk_writer = compressed_chunk_writer_class(
|
|
quality=db_data.image_quality, dimension=db_task.dimension
|
|
)
|
|
original_chunk_writer = original_chunk_writer_class(
|
|
quality=original_quality, dimension=db_task.dimension
|
|
)
|
|
|
|
db_segments = db_task.segment_set.order_by("start_frame").all()
|
|
|
|
frame_map = {} # frame number -> extractor frame number
|
|
|
|
if isinstance(media_extractor, MEDIA_TYPES["video"]["extractor"]):
|
|
|
|
def _get_frame_size(frame_tuple: tuple[av.VideoFrame, Any, Any]) -> int:
|
|
# There is no need to be absolutely precise here,
|
|
# just need to provide the reasonable upper boundary.
|
|
# Return bytes needed for 1 frame
|
|
frame = frame_tuple[0]
|
|
return frame.width * frame.height * (frame.format.padded_bits_per_pixel // 8)
|
|
|
|
# Currently, we only optimize video creation for sequential
|
|
# chunks with potential overlap, so parallel processing is likely to
|
|
# help only for image datasets
|
|
media_iterator = CachingMediaIterator(
|
|
media_extractor,
|
|
max_cache_memory=2**30,
|
|
max_cache_entries=db_task.overlap,
|
|
object_size_callback=_get_frame_size,
|
|
)
|
|
else:
|
|
extractor_frame_ids = {
|
|
media_extractor.get_path(abs_frame_number): abs_frame_number
|
|
for abs_frame_number in media_extractor.frame_range
|
|
}
|
|
|
|
frame_map = {
|
|
frame.frame: extractor_frame_ids[upload_dir / frame.path]
|
|
for frame in db_data.images.all()
|
|
}
|
|
|
|
media_iterator = RandomAccessIterator(media_extractor)
|
|
|
|
with closing(media_iterator):
|
|
progress_updater = _ChunkProgressUpdater()
|
|
|
|
# TODO: remove 2 * or the configuration option
|
|
# TODO: maybe make real multithreading support, currently the code is limited by 1
|
|
# video segment chunk, even if more threads are available
|
|
max_concurrency = (
|
|
2 * settings.CVAT_CONCURRENT_CHUNK_PROCESSING
|
|
if not isinstance(media_extractor, MEDIA_TYPES["video"]["extractor"])
|
|
else 2
|
|
)
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_concurrency) as executor:
|
|
for segment_idx, db_segment in enumerate(db_segments):
|
|
frame_counter = itertools.count()
|
|
for chunk_idx, chunk_frame_ids in (
|
|
(chunk_idx, tuple(chunk_frame_ids))
|
|
for chunk_idx, chunk_frame_ids in itertools.groupby(
|
|
(
|
|
# Convert absolute to relative ids (extractor output positions)
|
|
# Extractor will skip frames outside requested
|
|
(abs_frame_id - media_extractor.start) // media_extractor.step
|
|
for abs_frame_id in (
|
|
frame_map.get(frame, frame)
|
|
for frame in sorted(db_segment.frame_set)
|
|
)
|
|
),
|
|
lambda _: next(frame_counter) // db_data.chunk_size,
|
|
)
|
|
):
|
|
save_chunks(executor, db_segment, chunk_idx, chunk_frame_ids)
|
|
|
|
progress_updater.update_progress(segment_idx / len(db_segments))
|
|
|
|
|
|
def _move_to_backing_cs_if_configured(db_data):
|
|
backing_cs_id = settings.DEFAULT_BACKING_CS_ID
|
|
if backing_cs_id is not None and db_data.supports_backing_cs():
|
|
try:
|
|
backing_cs = models.CloudStorage.objects.get(pk=backing_cs_id)
|
|
except models.CloudStorage.DoesNotExist:
|
|
slogger.glob.warning(
|
|
f"Cloud storage #{backing_cs_id} (configured as default backing CS) does not exist"
|
|
)
|
|
else:
|
|
db_data.move_to_backing_cs(backing_cs)
|