model_train_dm/backend/apps/datasets/storage_service.py
2026-07-27 17:51:49 +08:00

503 lines
17 KiB
Python

import io
import mimetypes
import os
import uuid
import zipfile
from datetime import datetime
from tempfile import NamedTemporaryFile
from django.conf import settings
from django.db.models import Max
from PIL import Image, UnidentifiedImageError
from apps.common.minio_client import minio_storage
from apps.common.utils.annotation_store import split_dataset_sample_annotation_content
from apps.core.models import AiDatasetSample
try:
import cv2
except Exception:
cv2 = None
DATASET_BUCKET = "dataset"
TEMPFILE_BUCKET = "tempfile"
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tif", ".tiff"}
VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".mpeg", ".mpg", ".webm"}
AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".m4a", ".flac", ".ogg"}
TEXT_EXTENSIONS = {".txt", ".csv", ".json", ".xml"}
def _local_sample_path(train_path: str,saved_path: str, saved_filename: str) -> str:
return os.path.join(train_path, saved_path, saved_filename).replace("\\", "/")
def _sample_object_name(saved_path: str, saved_filename: str) -> str:
return f"{str(saved_path or '').strip('/').replace('\\', '/')}/{saved_filename}".strip("/")
def _ensure_local_dir(saved_path: str) -> str:
local_dir = os.path.join(settings.FILESPACE_ROOT_PATH, saved_path).replace("\\", "/")
os.makedirs(local_dir, exist_ok=True)
return local_dir
def _write_local_file(saved_path: str, saved_filename: str, data: bytes) -> str:
local_dir = _ensure_local_dir(saved_path)
full_path = os.path.join(local_dir, saved_filename).replace("\\", "/")
with open(full_path, "wb") as f:
f.write(data)
return full_path
def _sidecar_filename(saved_filename: str, suffix: str) -> str:
stem = os.path.splitext(saved_filename)[0]
normalized_suffix = suffix if str(suffix).startswith(".") else f".{suffix}"
return f"{stem}{normalized_suffix}"
def sample_sidecar_names(saved_filename: str):
return [
_sidecar_filename(saved_filename, ".xml"),
_sidecar_filename(saved_filename, ".json"),
]
def _normalize_filesize(size_bytes: int) -> str:
try:
return str(min(max(int(size_bytes or 0), 0), 9999999999))
except Exception:
return "0"
def _image_wh_from_bytes(data: bytes):
try:
with Image.open(io.BytesIO(data)) as img:
width, height = img.size
return f"{width}x{height}"
except UnidentifiedImageError:
return None
except Exception:
return None
def _extract_sample_index(saved_filename: str) -> int | None:
stem = os.path.splitext(os.path.basename(str(saved_filename or "")))[0]
tail = stem.rsplit("-", 1)[-1]
if tail.isdigit():
return int(tail)
return None
def _next_sample_index(dataset_id: str) -> int:
max_saved_filename = AiDatasetSample.objects.filter(dataset_id=dataset_id).aggregate(
max_saved_filename=Max("saved_filename")
)["max_saved_filename"]
current_index = _extract_sample_index(max_saved_filename)
return (current_index or 0) + 1
def _build_saved_filename(dataset, original_filename: str) -> str:
ext = os.path.splitext(original_filename or "")[1].lower()
index = _next_sample_index(dataset.id)
return f"{dataset.dataset_code}-{index:08}{ext}"
def detect_original_type(filename: str) -> str:
ext = os.path.splitext(filename or "")[1].lower()
if ext in IMAGE_EXTENSIONS:
return "01"
if ext in VIDEO_EXTENSIONS:
return "02"
if ext in AUDIO_EXTENSIONS:
return "03"
if ext == ".zip":
return "04"
if ext in TEXT_EXTENSIONS:
return "05"
return "99"
def dataset_type_accepts_extension(dataset_type: str, extension: str) -> bool:
ext = str(extension or "").lower()
if dataset_type == "02":
return ext in IMAGE_EXTENSIONS
if dataset_type == "03":
return ext in VIDEO_EXTENSIONS
if dataset_type == "04":
return ext in AUDIO_EXTENSIONS
if dataset_type == "05":
return ext in TEXT_EXTENSIONS
return ext in IMAGE_EXTENSIONS | VIDEO_EXTENSIONS | AUDIO_EXTENSIONS | TEXT_EXTENSIONS
def create_dataset_sample_record(
dataset,
original_filename: str,
saved_filename: str,
size_bytes: int,
annotation_content=None,
custom1=None,
custom2=None,
custom3=None,
status: str = "00",
):
annotation_fields = split_dataset_sample_annotation_content(annotation_content)
return AiDatasetSample.objects.create(
id=str(uuid.uuid4()),
dataset_id=dataset.id,
saved_path=dataset.dataset_path,
original_filename=original_filename,
saved_filename=saved_filename,
saved_filesize=_normalize_filesize(size_bytes),
rectangle=annotation_fields["rectangle"],
polygon=annotation_fields["polygon"],
status=status,
create_time=datetime.now(),
custom1=custom1,
custom2=custom2,
custom3=custom3,
)
def save_bytes_to_dataset(
dataset,
original_filename: str,
data: bytes,
content_type: str | None = None,
annotation_content=None,
custom1=None,
custom2=None,
custom3=None,
):
saved_filename = _build_saved_filename(dataset, original_filename)
object_name = _sample_object_name(dataset.dataset_path, saved_filename)
actual_content_type = content_type or mimetypes.guess_type(original_filename)[0] or "application/octet-stream"
minio_storage.upload_bytes(DATASET_BUCKET, object_name, data, content_type=actual_content_type)
if custom1 is None and os.path.splitext(saved_filename)[1].lower() in IMAGE_EXTENSIONS:
custom1 = _image_wh_from_bytes(data)
sample = create_dataset_sample_record(
dataset=dataset,
original_filename=os.path.basename(original_filename),
saved_filename=saved_filename,
size_bytes=len(data),
annotation_content=annotation_content,
custom1=custom1,
custom2=custom2,
custom3=custom3,
)
return sample
def save_uploaded_file_to_dataset(dataset, uploaded_file):
file_bytes = uploaded_file.read()
uploaded_file.seek(0)
return save_bytes_to_dataset(
dataset=dataset,
original_filename=uploaded_file.name,
data=file_bytes,
content_type=uploaded_file.content_type,
)
def copy_minio_object_to_dataset(
dataset,
source_bucket: str,
source_object: str,
original_filename: str,
size_bytes: int | None = None,
custom2=None,
custom3=None,
):
saved_filename = _build_saved_filename(dataset, original_filename)
target_object = _sample_object_name(dataset.dataset_path, saved_filename)
minio_storage.copy_object(source_bucket, source_object, DATASET_BUCKET, target_object)
actual_size = size_bytes
if actual_size is None:
try:
actual_size = int(minio_storage.get_object_info(DATASET_BUCKET, target_object).get("size") or 0)
except Exception:
actual_size = 0
custom1 = None
if os.path.splitext(saved_filename)[1].lower() in IMAGE_EXTENSIONS:
try:
custom1 = _image_wh_from_bytes(minio_storage.download_bytes(DATASET_BUCKET, target_object))
except Exception:
custom1 = None
return create_dataset_sample_record(
dataset=dataset,
original_filename=os.path.basename(original_filename),
saved_filename=saved_filename,
size_bytes=actual_size or 0,
custom1=custom1,
custom2=custom2,
custom3=custom3,
)
def dataset_sample_source_exists(dataset_id: str, source_marker: str | None) -> bool:
if not source_marker:
return False
return AiDatasetSample.objects.filter(dataset_id=dataset_id, custom2=source_marker).exists()
def copy_minio_object_to_dataset_result(
dataset,
source_bucket: str,
source_object: str,
original_filename: str,
size_bytes: int | None = None,
source_marker: str | None = None,
skip_existing: bool = False,
):
if skip_existing and dataset_sample_source_exists(dataset.id, source_marker):
return {
"imported": [],
"skipped": [{"name": os.path.basename(original_filename), "reason": "duplicate_source"}],
"failed": [],
}
sample = copy_minio_object_to_dataset(
dataset=dataset,
source_bucket=source_bucket,
source_object=source_object,
original_filename=original_filename,
size_bytes=size_bytes,
custom2=source_marker,
)
return {"imported": [sample], "skipped": [], "failed": []}
def import_zip_bytes_to_dataset_result(
dataset,
zip_bytes: bytes,
source_marker_prefix: str | None = None,
skip_existing: bool = False,
):
imported = []
skipped = []
failed = []
with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zip_file:
for member in zip_file.infolist():
if member.is_dir():
continue
member_name = member.filename.replace("\\", "/")
filename = os.path.basename(member_name)
if not filename or filename.startswith("."):
continue
ext = os.path.splitext(filename)[1].lower()
if not dataset_type_accepts_extension(dataset.dataset_type, ext):
failed.append({"name": filename, "reason": "type_mismatch"})
continue
source_marker = f"{source_marker_prefix}:zip:{member_name}" if source_marker_prefix else None
if skip_existing and dataset_sample_source_exists(dataset.id, source_marker):
skipped.append({"name": filename, "reason": "duplicate_source"})
continue
try:
with zip_file.open(member, "r") as fp:
data = fp.read()
imported.append(save_bytes_to_dataset(dataset, filename, data, custom2=source_marker))
except Exception as exc:
failed.append({"name": filename, "reason": str(exc)})
return {"imported": imported, "skipped": skipped, "failed": failed}
def import_zip_bytes_to_dataset(dataset, zip_bytes: bytes, source_marker_prefix: str | None = None, skip_existing: bool = False):
result = import_zip_bytes_to_dataset_result(
dataset=dataset,
zip_bytes=zip_bytes,
source_marker_prefix=source_marker_prefix,
skip_existing=skip_existing,
)
return result["imported"]
def extract_video_frames_to_dataset_result(
dataset,
video_bytes: bytes,
original_filename: str,
interval_seconds: int = 1,
max_frames: int = 200,
image_format: str = "jpg",
source_marker_prefix: str | None = None,
skip_existing: bool = False,
):
if cv2 is None:
raise RuntimeError("opencv-python 未安装,无法处理视频")
video_ext = os.path.splitext(original_filename or "")[1].lower() or ".mp4"
temp_path = None
cap = None
imported = []
skipped = []
failed = []
try:
with NamedTemporaryFile(delete=False, suffix=video_ext) as temp_file:
temp_file.write(video_bytes)
temp_path = temp_file.name
cap = cv2.VideoCapture(temp_path)
if not cap.isOpened():
raise RuntimeError("视频文件无法打开")
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
step_frames = max(int(round(interval_seconds * fps)), 1)
current_frame = 0
video_stem = os.path.splitext(os.path.basename(original_filename))[0]
suffix = "jpg" if image_format == "jpeg" else image_format
encode_ext = ".jpg" if image_format in {"jpg", "jpeg"} else ".png"
while len(imported) < max_frames:
ok, frame = cap.read()
if not ok:
break
if current_frame % step_frames == 0:
source_marker = (
f"{source_marker_prefix}:frame:{current_frame:08}"
if source_marker_prefix
else None
)
if skip_existing and dataset_sample_source_exists(dataset.id, source_marker):
skipped.append(
{
"name": f"{video_stem}_{current_frame:08}.{suffix}",
"reason": "duplicate_source",
}
)
current_frame += 1
continue
ok_encode, encoded = cv2.imencode(encode_ext, frame)
if ok_encode:
frame_name = f"{video_stem}_{current_frame:08}.{suffix}"
imported.append(
save_bytes_to_dataset(
dataset,
frame_name,
encoded.tobytes(),
custom2=source_marker,
)
)
else:
failed.append(
{
"name": f"{video_stem}_{current_frame:08}.{suffix}",
"reason": "frame_encode_failed",
}
)
current_frame += 1
finally:
if cap is not None:
cap.release()
if temp_path and os.path.exists(temp_path):
os.unlink(temp_path)
return {"imported": imported, "skipped": skipped, "failed": failed}
def extract_video_frames_to_dataset(
dataset,
video_bytes: bytes,
original_filename: str,
interval_seconds: int = 1,
max_frames: int = 200,
image_format: str = "jpg",
source_marker_prefix: str | None = None,
skip_existing: bool = False,
):
result = extract_video_frames_to_dataset_result(
dataset=dataset,
video_bytes=video_bytes,
original_filename=original_filename,
interval_seconds=interval_seconds,
max_frames=max_frames,
image_format=image_format,
source_marker_prefix=source_marker_prefix,
skip_existing=skip_existing,
)
return result["imported"]
def ensure_sample_local_copy(train_path: str, saved_path: str, saved_filename: str):
local_path = _local_sample_path(train_path, saved_path, saved_filename)
if os.path.exists(local_path):
return local_path
object_name = _sample_object_name(saved_path, saved_filename)
_ensure_local_dir(saved_path)
minio_storage.download_file(DATASET_BUCKET, object_name, local_path)
return local_path
def ensure_sidecar_local_copy(saved_path: str, saved_filename: str, suffix: str):
sidecar_name = _sidecar_filename(saved_filename, suffix)
local_path = _local_sample_path(saved_path, sidecar_name)
if os.path.exists(local_path):
return local_path
object_name = _sample_object_name(saved_path, sidecar_name)
if not minio_storage.object_exists(DATASET_BUCKET, object_name):
return local_path
_ensure_local_dir(saved_path)
minio_storage.download_file(DATASET_BUCKET, object_name, local_path)
return local_path
def download_sidecar_bytes(saved_path: str, saved_filename: str, suffix: str):
sidecar_name = _sidecar_filename(saved_filename, suffix)
object_name = _sample_object_name(saved_path, sidecar_name)
try:
if minio_storage.object_exists(DATASET_BUCKET, object_name):
return minio_storage.download_bytes(DATASET_BUCKET, object_name)
except Exception:
pass
local_path = _local_sample_path(saved_path, sidecar_name)
if os.path.exists(local_path) and os.path.isfile(local_path):
with open(local_path, "rb") as f:
return f.read()
return None
def download_sidecar_text(saved_path: str, saved_filename: str, suffix: str, encoding: str = "utf-8"):
data = download_sidecar_bytes(saved_path, saved_filename, suffix)
if data is None:
return None
return data.decode(encoding, errors="replace")
def upload_sidecar_bytes(saved_path: str, saved_filename: str, suffix: str, data: bytes, content_type: str | None = None):
sidecar_name = _sidecar_filename(saved_filename, suffix)
object_name = _sample_object_name(saved_path, sidecar_name)
actual_content_type = content_type or mimetypes.guess_type(sidecar_name)[0] or "application/octet-stream"
minio_storage.upload_bytes(DATASET_BUCKET, object_name, data, content_type=actual_content_type)
return sidecar_name
def upload_sidecar_text(saved_path: str, saved_filename: str, suffix: str, text: str, content_type: str | None = None):
actual_content_type = content_type
if actual_content_type is None and str(suffix).lower().endswith("json"):
actual_content_type = "application/json"
return upload_sidecar_bytes(
saved_path=saved_path,
saved_filename=saved_filename,
suffix=suffix,
data=(text or "").encode("utf-8"),
content_type=actual_content_type,
)
def delete_sample_storage(saved_path: str, saved_filename: str):
candidates = [saved_filename] + sample_sidecar_names(saved_filename)
failed_objects = []
for filename in candidates:
object_name = _sample_object_name(saved_path, filename)
try:
if minio_storage.object_exists(DATASET_BUCKET, object_name):
minio_storage.delete_object(DATASET_BUCKET, object_name)
except Exception:
failed_objects.append(object_name)
local_path = os.path.join(settings.FILESPACE_ROOT_PATH, saved_path, filename).replace("\\", "/")
if os.path.exists(local_path) and os.path.isfile(local_path):
try:
os.remove(local_path)
except Exception:
pass
return failed_objects